feat: media bitrate control, HDR (#346)

Fixes: #335 

Still a work-in-progress.

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
Kristian Ollikainen
2026-09-25 12:13:34 +03:00
committed by GitHub
co-authored by DatCaptainHorse Claude Opus 5 Wanjohi
parent 1c721962f4
commit 0811f57f1a
64 changed files with 15151 additions and 2702 deletions
+1
View File
@@ -14,6 +14,7 @@
pub mod filesystems;
pub mod payload;
pub mod platform;
pub mod reap;
pub mod services;
pub mod session;
+2
View File
@@ -45,6 +45,7 @@ fn main() -> anyhow::Result<()> {
// nothing else in this guest is an init system, so until this runs there
// is no `/proc` to score this process in and nowhere to put a socket.
nesinit::filesystems::establish();
nesinit::platform::describe();
// Everything a distribution's init scripts used to do, and nothing else is
// going to: a hostname, the box's address, the directories a session's
@@ -80,6 +81,7 @@ fn main() -> anyhow::Result<()> {
Ok(outcome) => tracing::info!(?outcome, "the session ended"),
Err(error) => tracing::error!(%error, "the session failed"),
}
nesinit::platform::report_steal();
// Before anything below waits on a pid: the reaper runs on this runtime's
// threads, and two things calling `wait` is what the registry exists to
+148
View File
@@ -0,0 +1,148 @@
// What the kernel under this box actually chose, said once, at debug.
//
// Several of the settings that decide a box's latency are not decided by the
// image. The clocksource is picked at boot and can be demoted by a watchdog,
// the idle driver loads only when the host or the command line asks for it,
// and the preemption model is a boot parameter. None of them is visible from
// outside, and a box has no shell to ask with, so this reads them from sysfs
// and procfs and logs them. Enable with `RUST_LOG=nesinit=debug` on the kernel
// command line.
use std::path::Path;
/// Clocksources a vDSO can read without entering the kernel.
///
/// Anything else makes every `clock_gettime` a syscall, and in a guest an
/// emulated one: `hpet` is an MMIO read the host has to trap. A Windows game
/// polls its performance counter constantly, so that is a cost paid thousands
/// of times a frame, and nothing reports it.
const VDSO_CLOCKSOURCES: &[&str] = &["tsc", "kvm-clock"];
/// Log the kernel's timing and idle choices. Needs `/proc` and `/sys`.
pub fn describe() {
let clocksource = read("/sys/devices/system/clocksource/clocksource0/current_clocksource");
let cmdline = read("/proc/cmdline").unwrap_or_default();
tracing::debug!(
clocksource = clocksource.as_deref().unwrap_or("unknown"),
available = read("/sys/devices/system/clocksource/clocksource0/available_clocksource")
.as_deref()
.unwrap_or("unknown"),
idle_driver = read("/sys/devices/system/cpu/cpuidle/current_driver")
.as_deref()
.unwrap_or("none"),
idle_governor = read("/sys/devices/system/cpu/cpuidle/current_governor_ro")
.as_deref()
.unwrap_or("none"),
// Present only while the haltpoll governor is built in, and only
// meaningful while it is the governor in use.
halt_poll_ns = read("/sys/module/haltpoll/parameters/guest_halt_poll_ns")
.as_deref()
.unwrap_or("n/a"),
// The build's default when absent. The mode actually in effect is only
// readable through debugfs, which this kernel does not have.
preempt = kernel_parameter(&cmdline, "preempt").unwrap_or("default"),
cpus = std::thread::available_parallelism().map_or(0, usize::from),
kernel = read("/proc/sys/kernel/version")
.as_deref()
.unwrap_or("unknown"),
"platform"
);
if let Some(source) = clocksource.as_deref()
&& !VDSO_CLOCKSOURCES.contains(&source)
{
tracing::warn!(
clocksource = source,
"every clock read in this box is a syscall; the kernel did not trust a faster clock"
);
}
}
/// Log how much of this box's CPU time the host took back. Call once, at the
/// end of a session: the counters are cumulative since boot.
pub fn report_steal() {
let Some(stat) = read("/proc/stat") else {
return;
};
let Some((steal, total)) = steal_of(&stat) else {
return;
};
// Parts per thousand, so the log carries an integer and no float
// formatting decides how small a number reads as zero.
let permille = (steal * 1000).checked_div(total).unwrap_or(0);
tracing::debug!(
steal_ticks = steal,
total_ticks = total,
permille,
"cpu time stolen by the host"
);
}
/// Steal and total ticks from the aggregate `cpu` line of `/proc/stat`.
///
/// Columns are user, nice, system, idle, iowait, irq, softirq, steal, and then
/// guest time, which the kernel already counts inside user and nice. Summing
/// past steal would count it twice.
fn steal_of(stat: &str) -> Option<(u64, u64)> {
let line = stat.lines().find(|l| l.starts_with("cpu "))?;
let fields: Vec<u64> = line
.split_whitespace()
.skip(1)
.take(8)
.map(str::parse)
.collect::<Result<_, _>>()
.ok()?;
let steal = *fields.get(7)?;
Some((steal, fields.iter().sum()))
}
/// The value of one bare `name=value` kernel parameter.
fn kernel_parameter<'a>(cmdline: &'a str, name: &str) -> Option<&'a str> {
cmdline
.split_whitespace()
.find_map(|word| word.strip_prefix(name)?.strip_prefix('='))
.filter(|value| !value.is_empty())
}
fn read(path: impl AsRef<Path>) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_parameter_is_found_by_its_whole_name() {
let cmdline = "cpuidle_haltpoll.force=1 console=hvc0 preempt=full ro";
assert_eq!(kernel_parameter(cmdline, "preempt"), Some("full"));
assert_eq!(
kernel_parameter(cmdline, "cpuidle_haltpoll.force"),
Some("1")
);
}
/// `preempt` must not match `preempt_foo=`, which is a different parameter.
#[test]
fn a_longer_name_sharing_the_prefix_is_not_a_match() {
assert_eq!(kernel_parameter("preempt_foo=x", "preempt"), None);
assert_eq!(kernel_parameter("console=hvc0", "preempt"), None);
}
#[test]
fn steal_is_the_eighth_column_and_guest_time_is_not_counted_twice() {
// user nice system idle iowait irq softirq steal guest guest_nice
let stat = "cpu 100 0 50 800 10 5 5 30 999 999\ncpu0 1 2 3 4 5 6 7 8 9 10\n";
assert_eq!(steal_of(stat), Some((30, 1000)));
}
#[test]
fn a_stat_without_a_cpu_line_reports_nothing() {
assert_eq!(steal_of("intr 1 2 3\n"), None);
assert_eq!(steal_of("cpu 1 2 3\n"), None);
}
}
+99 -10
View File
@@ -41,6 +41,7 @@ use tokio::sync::mpsc::{Receiver, Sender};
use crate::reap::{Waiters, Watched};
use crate::workload::Failure;
use nesprotocol::lifecycle::VideoLimits;
/// A service that died, and how.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -60,7 +61,13 @@ pub trait Services {
/// Called once, after the shares are mounted and before anything may be
/// launched. An empty stack is legitimate: a box with no services still
/// boots, and a caller can still launch something that needs none.
fn bring_up(&mut self) -> Result<Vec<String>, Failure>;
///
/// `video` comes from the descriptor and reaches the services that read it.
/// It has to arrive here rather than later because a service configured
/// after it is already running has a window in which it is not configured,
/// and for a bitrate ceiling that window is a session streaming at whatever
/// default it started with.
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure>;
/// Deaths, as they happen.
///
@@ -160,6 +167,17 @@ pub const SERVICE_UID: u32 = 1000;
/// here: the directory belongs to the service user and is not writable by the
/// workload, which is the property [`crate::ticket::Untrusted`] depends on.
pub const AUDIO_DIR: &str = "/run/pipewire";
/// Where the PulseAudio protocol is served, in [`AUDIO_DIR`] for the reason
/// audio's own socket is.
///
/// pipewire-pulse is told this path by its configuration file in the image,
/// which is not something this program can pass it, so the two are compared by
/// a test rather than trusted to agree.
pub const PULSE_SOCKET: &str = "/run/pipewire/pulse-native";
/// [`PULSE_SOCKET`] as a PulseAudio client is told it.
pub const PULSE_SERVER: &str = "unix:/run/pipewire/pulse-native";
pub const SERVICE_GID: u32 = 1000;
/// Where a service's runtime sockets live.
@@ -273,6 +291,26 @@ pub const STACK: &[Service] = &[
umask: None,
ready: None,
},
Service {
name: "pipewire-pulse",
argv: &["/usr/bin/pipewire-pulse"],
env: &[
("XDG_RUNTIME_DIR", RUNTIME_DIR),
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
],
user: Some((SERVICE_UID, SERVICE_GID)),
// Optional for the reason the sender is: everything that speaks
// PipeWire itself is unaffected, and a session with sound missing is
// degraded rather than unusable.
cost: "anything that only speaks PulseAudio plays silently, and Wine is one",
required: false,
// The workload is its client, and is not this user.
umask: Some(0),
// Nothing in this table connects to it, but the workload does, and the
// workload is started after the table.
ready: Some(PULSE_SOCKET),
},
Service {
name: "neswire",
argv: &["/usr/bin/neswire"],
@@ -308,6 +346,13 @@ pub struct Stack {
running: Vec<(&'static str, Watched)>,
deaths: Receiver<Died>,
reported: Sender<Died>,
/// What the host said this box may spend on video, from the descriptor.
///
/// Held here because `spawn` is where it reaches a service, and `spawn`
/// takes a `&'static Service` whose `env` is a fixed table -- a value that
/// arrives at runtime has no route through it otherwise. The same problem
/// `RUST_LOG` has, solved the same way.
video: VideoLimits,
}
impl Stack {
@@ -328,6 +373,7 @@ impl Stack {
running: Vec::new(),
deaths,
reported,
video: VideoLimits::default(),
}
}
@@ -390,6 +436,15 @@ impl Stack {
if let Ok(filter) = std::env::var("RUST_LOG") {
command.env("RUST_LOG", filter);
}
// The descriptor's video limits, for the services that read them. Same
// shape of problem as `RUST_LOG` above -- `env_clear` drops everything
// and the service table is a fixed list of literals, so a value that
// only exists at runtime has no other route in. `neshub` reads this
// through the clap `env =` attribute it already uses for every other
// setting.
if let Some(kbps) = self.video.bitrate_kbps {
command.env("NESTRI_MAX_BITRATE", kbps.to_string());
}
// The service's own entry last, so a service that states one of these
// for itself wins over the defaults above.
command.envs(service.env.iter().copied());
@@ -451,7 +506,8 @@ impl Stack {
}
impl Services for Stack {
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
self.video = video;
let mut up = Vec::new();
// Lifted out so the loop does not hold a borrow of `self` across the
// start it is asking for.
@@ -604,6 +660,9 @@ pub mod double {
/// only thing under test.
pub struct Double {
pub brought_up: usize,
/// What the last `bring_up` was told, so a test can assert the limits
/// reached the stack rather than assuming they did.
pub video: VideoLimits,
pub failure: Option<Failure>,
pub names: Vec<String>,
deaths: Receiver<Died>,
@@ -625,6 +684,7 @@ pub mod double {
names: vec!["dbus-system".into(), "neshub".into()],
deaths,
report,
video: VideoLimits::default(),
}
}
@@ -637,8 +697,9 @@ pub mod double {
}
impl Services for Double {
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
self.brought_up += 1;
self.video = video;
match &self.failure {
Some(failure) => Err(failure.clone()),
None => Ok(self.names.clone()),
@@ -853,14 +914,42 @@ mod tests {
/// owner -- and the workload is not the owner.
#[test]
fn the_audio_socket_is_reachable_by_a_user_who_does_not_own_it() {
let pipewire = STACK
for name in ["pipewire", "pipewire-pulse"] {
let service = STACK
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("{name} is in the table"));
assert_eq!(
service.umask,
Some(0),
"with any other umask the game finds {name}'s socket and cannot open it"
);
}
}
/// The PulseAudio socket's path is written three times: here, in the
/// client address the workload is given, and in pipewire-pulse's own
/// configuration in the image. If any of them moves on its own, the game
/// finds no server and plays silently, and nothing fails.
#[test]
fn pulse_is_served_where_the_workload_is_told_to_look() {
assert!(
PULSE_SOCKET.starts_with(AUDIO_DIR),
"the workload can only reach sockets in {AUDIO_DIR}"
);
assert_eq!(PULSE_SERVER, format!("unix:{PULSE_SOCKET}"));
let pulse = STACK
.iter()
.find(|s| s.name == "pipewire")
.expect("audio is in the table");
assert_eq!(
pipewire.umask,
Some(0),
"with any other umask the game finds the socket and cannot open it"
.find(|s| s.name == "pipewire-pulse")
.expect("pulse is in the table");
assert_eq!(pulse.ready, Some(PULSE_SOCKET));
let config =
include_str!("../../../build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf");
assert!(
config.contains(&format!("\"{PULSE_SERVER}\"")),
"pipewire-pulse is configured to listen somewhere other than {PULSE_SOCKET}"
);
}
+56 -8
View File
@@ -266,19 +266,19 @@ where
}
booted = true;
// The drives, then the shares, then the services.
// The overlays, then the shares, then the services.
//
// **Drives first, and one `Mounted` between them.** A drive is
// a filesystem this end mounts itself, so a share whose target
// lives under one has to find it already there. The host is
// **Overlays first, and one `Mounted` between them.** An
// overlay is a filesystem this end mounts itself, so a share
// whose target lives under one has to find it already there. The host is
// told once, after both, because `Mounted` answers "is the
// content where the descriptor said" and there is one answer to
// that -- sending it twice made the host read the second as a
// reply to something it had not asked.
if let Err(failure) = workload.mount_drives(&descriptor.drives) {
if let Err(failure) = workload.mount_overlays(&descriptor.overlays) {
// Said before it is returned. `Refused` ends the session
// either way; without the message the host sees a box that
// stopped and has to guess between a drive, a share and a
// stopped and has to guess between an overlay, a share and a
// service -- which is the whole reason these are reported
// separately.
send(
@@ -311,7 +311,7 @@ where
// A box whose own services will not come up cannot be launched
// into, so this is refused rather than reported and carried on
// from — unlike a launch, which is the caller's to correct.
match services.bring_up() {
match services.bring_up(descriptor.video) {
Ok(up) => {
tracing::info!(services = up.len(), "the box is ready to be launched into");
send(&mut writer, &GuestToHost::Initialized { services: up }).await?
@@ -516,7 +516,8 @@ mod tests {
at: "/mnt/user".into(),
ro: false,
}],
drives: Vec::new(),
overlays: Vec::new(),
video: Default::default(),
}
}
@@ -715,6 +716,53 @@ mod tests {
);
}
#[tokio::test]
async fn the_descriptors_video_limits_reach_the_services() {
// The ceiling is useless if it stops at the descriptor. `neshub` is the
// only thing that can enforce it and it is a service, so the number has
// to survive the whole way from the boot document to the spawn.
let (guest, host) = tokio::io::duplex(4096);
let mut caller = Caller::new(host);
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
let mut given = descriptor();
given.video.bitrate_kbps = Some(8_000);
caller.expect_ready().await;
caller
.say(&HostToGuest::Boot {
descriptor: Box::new(given),
})
.await;
caller.expect_booted().await;
caller.say(&HostToGuest::Shutdown).await;
let (_, _, services) = session.await.unwrap();
assert_eq!(services.video.bitrate_kbps, Some(8_000));
}
#[tokio::test]
async fn a_box_told_nothing_about_video_says_so_rather_than_inventing_a_limit() {
// "Unsaid" must not arrive as a number. A stack that cannot tell the two
// apart cannot log that it was never told, and a ceiling nobody set is
// exactly how every session came to offer 10 Mbps.
let (guest, host) = tokio::io::duplex(4096);
let mut caller = Caller::new(host);
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
caller.expect_ready().await;
caller
.say(&HostToGuest::Boot {
descriptor: Box::new(descriptor()),
})
.await;
caller.expect_booted().await;
caller.say(&HostToGuest::Shutdown).await;
let (_, _, services) = session.await.unwrap();
assert_eq!(services.video.bitrate_kbps, None);
}
#[tokio::test]
async fn a_launch_runs_what_it_names_and_is_reported_by_its_id() {
let (guest, host) = tokio::io::duplex(4096);
+271 -81
View File
@@ -11,7 +11,7 @@ use std::future::Future;
use std::io;
use std::pin::Pin;
use nesprotocol::lifecycle::{Drive, Exec, Exit, Mount};
use nesprotocol::lifecycle::{Exec, Exit, Mount, Overlay};
use std::os::unix::process::CommandExt;
@@ -41,8 +41,9 @@ pub trait Workload {
/// Make the shares the descriptor names, where it says to put them.
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>;
/// Mount drives
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure>;
/// Stack each overlay the descriptor names: its build image, its writable
/// layer, and the two together where it says.
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>;
/// Start the command the descriptor names.
///
@@ -132,9 +133,9 @@ impl Workload for Process {
Ok(())
}
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
for drive in drives {
mount_drive(drive)?;
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
for overlay in overlays {
mount_overlay(overlay)?;
}
Ok(())
}
@@ -302,6 +303,9 @@ const GRAPHICS: &[(&str, &str)] = &[
// reach and both are told where. Without this a game renders and plays
// silently, having looked under its own uid and found nothing.
("PIPEWIRE_RUNTIME_DIR", crate::services::AUDIO_DIR),
// The same, for a client that speaks PulseAudio instead. It does not read
// the variable above, and its default is under its own runtime directory.
("PULSE_SERVER", crate::services::PULSE_SERVER),
];
/// Mount one share where the descriptor says to put it.
@@ -340,35 +344,104 @@ fn mount_share(share: &Mount) -> Result<(), Failure> {
/// filesystem this mounts. A descriptor cannot name another.
const FSTYPE: &std::ffi::CStr = c"virtiofs";
/// Mounts block device instead of virtiofs share
fn mount_drive(drive: &Drive) -> Result<(), Failure> {
// Checked before anything is created: a descriptor this component cannot
// act on should leave no directory behind to confuse whoever reads the
// failure.
let (source, target, flags) = options_drive(drive)?;
/// Stack one overlay: the build image, the box's writable layer, and the two
/// together at `at`.
///
/// Each step's failure names the step, because "the install did not mount"
/// has three different causes and each one is fixed somewhere else: a build
/// image the kernel cannot read, an upper layer that was never formatted, and
/// an overlay the kernel refused.
fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> {
// Everything that can be refused without touching the filesystem is
// refused first, so a descriptor this cannot act on leaves nothing behind.
let plan = OverlayPlan::new(overlay)?;
// The mount point may not exist yet: a share can land anywhere the
// descriptor names, including a directory no image created.
std::fs::create_dir_all(&drive.at).map_err(|error| failed_drive(drive, error))?;
mount_one(
&plan.lower,
&plan.lower_at,
c"erofs",
plan.lower_flags,
None,
)
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
mount_one(&plan.upper, &plan.rw_at, c"ext4", plan.upper_flags, None)
.map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?;
// SAFETY: mount takes two paths, a filesystem name and a flag word, all
// of which outlive the call, and no options string.
for dir in [&plan.upper_dir, &plan.work_dir] {
std::fs::create_dir_all(as_path(dir))
.map_err(|error| plan.failed("the writable layer", dir, error))?;
}
// **The upper directory takes the build's root ownership.** overlayfs
// shows a merged directory with the attributes of its upper half when it
// has one, and this one always does -- so an upper directory this init
// created, `root:root 0755`, would make the install's top directory
// unwritable to the workload, whatever the build image says. Copied from
// the lower root rather than named here: which uid the workload runs as is
// the host's decision, and the host already made it when it packed the
// image.
let (uid, gid, mode) = ownership(&plan.lower_at)
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
set_ownership(&plan.upper_dir, uid, gid, mode)
.map_err(|error| plan.failed("the writable layer", &plan.upper_dir, error))?;
mount_one(
c"overlay",
&plan.at,
c"overlay",
plan.overlay_flags,
Some(&plan.overlay_data),
)
.map_err(|error| plan.failed("the overlay", &plan.at, error))?;
Ok(())
}
/// Mount one filesystem, creating its mount point first.
fn mount_one(
source: &std::ffi::CStr,
target: &std::ffi::CStr,
fstype: &std::ffi::CStr,
flags: libc::c_ulong,
data: Option<&std::ffi::CStr>,
) -> io::Result<()> {
// The mount point may not exist yet: the descriptor can name anywhere,
// including a directory no image created.
std::fs::create_dir_all(as_path(target))?;
// SAFETY: every pointer is to a nul-terminated string that outlives the
// call, and a null data pointer is what mount(2) takes for "no options".
let mounted = unsafe {
libc::mount(
source.as_ptr(),
target.as_ptr(),
FSTYPE_DRIVE.as_ptr(),
fstype.as_ptr(),
flags,
std::ptr::null(),
data.map_or(std::ptr::null(), |d| d.as_ptr().cast()),
)
};
if mounted != 0 {
return Err(failed_drive(drive, io::Error::last_os_error()));
return Err(io::Error::last_os_error());
}
Ok(())
}
const FSTYPE_DRIVE: &std::ffi::CStr = c"ext4";
/// The same bytes, as a path: lossless, where a round trip through `str` is
/// not.
fn as_path(path: &std::ffi::CStr) -> &std::path::Path {
use std::os::unix::ffi::OsStrExt;
std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes()))
}
fn ownership(path: &std::ffi::CStr) -> io::Result<(u32, u32, u32)> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(as_path(path))?;
Ok((meta.uid(), meta.gid(), meta.mode() & 0o7777))
}
fn set_ownership(path: &std::ffi::CStr, uid: u32, gid: u32, mode: u32) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = as_path(path);
std::os::unix::fs::chown(path, Some(uid), Some(gid))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
/// What the mount call is given, split out because this is the part worth
/// asserting: mounting itself needs privileges a test does not have.
@@ -400,48 +473,114 @@ fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure>
Ok((source, target, flags))
}
/// What the drive mount call is given.
/// Everything an overlay's three mounts are given, worked out before any of
/// them is attempted.
///
/// # No filesystem-specific options, and that is a decision
/// Split out because this is the part worth asserting: mounting needs
/// privileges a test does not have.
///
/// `commit=` and `barrier=` were here once and the mount failed outright:
/// *"can't mount with commit=, fs mounted w/o journal"*, `EINVAL`, and a box
/// that refused its own descriptor before the session started. Both options
/// only mean anything to a journal, and a build volume is made without one --
/// what it holds is one game, re-downloadable, mounted by a clone that is
/// destroyed with the box. Anything added here has to be an option that is
/// still true of a journal-less ext4.
/// # Where the layers go
///
/// `noatime` stays: a game reading its own install has no use for access
/// times, and writing them turns every read of a clone into a write. It is not
/// paired with `nodiratime`, which it already implies.
/// Beside the overlay, in a hidden directory named after it:
/// `/nestri/install` stacks `/nestri/.install/lower` under
/// `/nestri/.install/rw/upper`. Beside rather than under, because anything
/// mounted under `at` is covered the moment the overlay is mounted over it.
///
/// # nosuid and nodev, for the same reason every share has them
/// # Flags
///
/// What this mounts is the least trusted thing in the box: files a CDN handed
/// us, checked for the bytes the manifest named and for nothing about what
/// those bytes are. A setuid binary or a device node inside a depot is not
/// something a workload should be able to use, and no descriptor has a way to
/// ask for one.
/// `nosuid` and `nodev` on every layer and on the result, for the reason every
/// share has them: what is stacked here is files a CDN handed us, checked for
/// the bytes the manifest named and for nothing about what those bytes are.
/// **Not `noexec`** anywhere: the game's executable is in the build.
///
/// **Not `noexec`.** The game's own executable is on this volume and the whole
/// point is to run it.
fn options_drive(drive: &Drive) -> Result<(CString, CString, libc::c_ulong), Failure> {
let flags = libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOATIME;
/// `noatime` on the writable layer and the overlay, so a game reading its own
/// install does not turn every read into a write. The build image is mounted
/// read-only and has no access times to write.
///
/// No filesystem-specific options on the upper layer: `commit=` and
/// `barrier=` were once passed to a journal-less ext4 and the mount failed
/// outright with `EINVAL`.
#[derive(Debug)]
struct OverlayPlan {
lower: CString,
upper: CString,
at: CString,
lower_at: CString,
rw_at: CString,
upper_dir: CString,
work_dir: CString,
lower_flags: libc::c_ulong,
upper_flags: libc::c_ulong,
overlay_flags: libc::c_ulong,
overlay_data: CString,
}
let source = CString::new(drive.dev.as_str()).map_err(|_| {
impl OverlayPlan {
fn new(overlay: &Overlay) -> Result<Self, Failure> {
let at = std::path::Path::new(&overlay.at);
let (Some(parent), Some(name)) = (at.parent(), at.file_name()) else {
return Err(Failure::new(format!(
"the overlay mount point has no parent to put its layers beside: {:?}",
overlay.at
)));
};
let layers = parent.join(format!(".{}", name.to_string_lossy()));
let lower_at = layers.join("lower");
let rw_at = layers.join("rw");
let upper_dir = rw_at.join("upper");
let work_dir = rw_at.join("work");
// overlayfs splits its options on commas and its layer lists on
// colons, and has no escape for either that this should rely on. A
// path carrying one would mount a different directory than the one
// named, so it is refused.
for path in [&lower_at, &upper_dir, &work_dir] {
let text = path.to_string_lossy();
if text.contains([',', ':']) {
return Err(Failure::new(format!(
"the overlay mount point cannot carry a comma or a colon: {:?}",
overlay.at
)));
}
}
let data = format!(
"lowerdir={},upperdir={},workdir={}",
lower_at.display(),
upper_dir.display(),
work_dir.display()
);
let common = libc::MS_NOSUID | libc::MS_NODEV;
Ok(Self {
lower: c_string(&overlay.lower, "the build image device")?,
upper: c_string(&overlay.upper, "the writable layer device")?,
at: c_string(&overlay.at, "the overlay mount point")?,
lower_at: c_string(&lower_at.to_string_lossy(), "the overlay mount point")?,
rw_at: c_string(&rw_at.to_string_lossy(), "the overlay mount point")?,
upper_dir: c_string(&upper_dir.to_string_lossy(), "the overlay mount point")?,
work_dir: c_string(&work_dir.to_string_lossy(), "the overlay mount point")?,
lower_flags: common | libc::MS_RDONLY,
upper_flags: common | libc::MS_NOATIME,
overlay_flags: common | libc::MS_NOATIME,
overlay_data: c_string(&data, "the overlay mount point")?,
})
}
/// Which step failed, on which path, in the operating system's words.
fn failed(&self, step: &str, path: &std::ffi::CStr, error: io::Error) -> Failure {
Failure::new(format!(
"the drive device contains a nul byte: {:?}",
drive.dev
"{}: {step}: {}: {error}",
self.at.to_string_lossy(),
path.to_string_lossy()
))
})?;
let target = CString::new(drive.at.as_str()).map_err(|_| {
Failure::new(format!(
"the drive mount point contains a nul byte: {:?}",
drive.at
))
})?;
Ok((source, target, flags))
}
}
/// A nul byte inside a path is a descriptor that cannot be carried out under
/// any flags. Refused by name rather than silently emptied: an empty path turns
/// up later as a mount failure about something else entirely.
fn c_string(text: &str, what: &str) -> Result<CString, Failure> {
CString::new(text).map_err(|_| Failure::new(format!("{what} contains a nul byte: {text:?}")))
}
/// A failure names the path, which is what makes it actionable: a permission
@@ -451,10 +590,6 @@ fn failed(share: &Mount, error: io::Error) -> Failure {
Failure::new(format!("{}: {error}", share.at))
}
fn failed_drive(drive: &Drive, error: io::Error) -> Failure {
Failure::new(format!("{}: {error}", drive.at))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -578,28 +713,83 @@ mod tests {
assert_eq!(flags & libc::MS_RDONLY, 0);
}
/// The drive carries the same guard every share carries.
///
/// It is the mount that most needs it: a share is a directory this host
/// prepared, and a drive is a filesystem built out of whatever a CDN sent.
fn overlay(at: &str) -> Overlay {
Overlay {
lower: "/dev/vdb".into(),
upper: "/dev/vdc".into(),
at: at.into(),
}
}
/// The layers sit beside the overlay, never under it: anything mounted
/// under `at` is hidden the moment the overlay covers it.
#[test]
fn a_drive_is_mounted_without_devices_or_setuid_but_can_still_execute() {
let drive = Drive {
dev: "/dev/vdb".into(),
at: "/nestri/install".into(),
};
let (source, target, flags) = options_drive(&drive).unwrap();
fn an_overlays_layers_sit_beside_it_and_the_options_name_them() {
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
assert_eq!(plan.lower.to_str().unwrap(), "/dev/vdb");
assert_eq!(plan.upper.to_str().unwrap(), "/dev/vdc");
assert_eq!(plan.lower_at.to_str().unwrap(), "/nestri/.install/lower");
assert_eq!(plan.rw_at.to_str().unwrap(), "/nestri/.install/rw");
assert_eq!(
source.to_str().unwrap(),
"/dev/vdb",
"the device is the source"
plan.overlay_data.to_str().unwrap(),
"lowerdir=/nestri/.install/lower,\
upperdir=/nestri/.install/rw/upper,\
workdir=/nestri/.install/rw/work"
);
}
/// Every layer carries the guard every share carries, and none of them
/// stops the game's own executable from running.
///
/// These are the mounts that most need it: a share is a directory this
/// host prepared, and a build is a filesystem made out of whatever a CDN
/// sent.
#[test]
fn every_layer_is_mounted_without_devices_or_setuid_but_can_still_execute() {
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
for flags in [plan.lower_flags, plan.upper_flags, plan.overlay_flags] {
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
assert_eq!(flags & libc::MS_NOEXEC, 0);
}
assert_eq!(plan.lower_flags & libc::MS_RDONLY, libc::MS_RDONLY);
assert_eq!(plan.upper_flags & libc::MS_RDONLY, 0);
assert_eq!(plan.overlay_flags & libc::MS_RDONLY, 0);
assert_eq!(plan.overlay_flags & libc::MS_NOATIME, libc::MS_NOATIME);
}
/// overlayfs splits its options on commas and colons, so a path carrying
/// one would stack a different directory than the one named.
#[test]
fn an_overlay_the_kernel_would_misread_is_refused_before_anything_mounts() {
for at in [
"/nestri/in,stall",
"/nestri/in:stall",
"/",
"/nestri/ins\0tall",
] {
assert!(
OverlayPlan::new(&overlay(at)).is_err(),
"{at:?} was accepted"
);
}
}
#[test]
fn a_failed_overlay_step_names_the_overlay_the_step_and_the_path() {
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
let failure = plan.failed(
"the build image",
&plan.lower_at,
io::Error::from_raw_os_error(libc::ENODEV),
);
assert!(
failure
.reason
.starts_with("/nestri/install: the build image: /nestri/.install/lower: "),
"{}",
failure.reason
);
assert_eq!(target.to_str().unwrap(), "/nestri/install");
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
assert_eq!(flags & libc::MS_NOATIME, libc::MS_NOATIME);
// The game's executable lives here.
assert_eq!(flags & libc::MS_NOEXEC, 0);
}
#[test]
@@ -652,7 +842,7 @@ pub mod double {
/// only thing under test.
pub struct Double {
pub mounted: Vec<Vec<Mount>>,
pub drives: Vec<Vec<Drive>>,
pub overlays: Vec<Vec<Overlay>>,
pub started: Vec<Exec>,
pub stops: usize,
pub mount_failure: Option<Failure>,
@@ -676,7 +866,7 @@ pub mod double {
fn new(exit: Exit, holds_until_stopped: bool) -> Self {
Self {
mounted: Vec::new(),
drives: Vec::new(),
overlays: Vec::new(),
started: Vec::new(),
stops: 0,
mount_failure: None,
@@ -697,8 +887,8 @@ pub mod double {
}
}
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
self.drives.push(drives.to_vec());
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
self.overlays.push(overlays.to_vec());
match &self.mount_failure {
Some(failure) => Err(failure.clone()),
None => Ok(()),
+3 -1
View File
@@ -50,7 +50,9 @@ fn alive(pid: i32) -> bool {
async fn a_stack_that_goes_away_takes_its_services_with_it() {
let waiters = Waiters::new();
let mut stack = Stack::from_table(waiters, SLEEPERS);
let up = stack.bring_up().expect("two sleeps did not start");
let up = stack
.bring_up(Default::default())
.expect("two sleeps did not start");
assert_eq!(up.len(), 2);
let pids = stack.pids();