cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/drop-hero-notarization-note
goq / crates / sigil-host / src / config.rs
config.rs 40.7K
1use std::collections::BTreeMap;2use std::fmt;3use std::fs::{self, OpenOptions};4use std::io::Read;5use std::path::{Path, PathBuf};67use anyhow::{Context, Result, ensure};8use serde::{Deserialize, Serialize};9use sha2::{Digest, Sha256};1011#[cfg(unix)]12use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};1314pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;15pub const DEFAULT_MAX_VIEWERS: u8 = 3;16pub const MAX_VIEWERS: u8 = 8;17const REVISION_PREFIX: &str = "sha256:";1819#[derive(Clone, Debug, PartialEq, Eq, Serialize)]20#[serde(transparent)]21pub struct ConfigRevision(String);2223impl ConfigRevision {24 pub fn from_bytes(bytes: &[u8]) -> Self {25 let digest = Sha256::digest(bytes);26 let mut value = String::with_capacity(REVISION_PREFIX.len() + digest.len() * 2);27 value.push_str(REVISION_PREFIX);28 for byte in digest {29 use fmt::Write as _;30 write!(&mut value, "{byte:02x}").expect("writing into a String cannot fail");31 }32 Self(value)33 }3435 pub fn parse(value: &str) -> Result<Self> {36 let digest = value.strip_prefix(REVISION_PREFIX).unwrap_or_default();37 ensure!(38 digest.len() == 6439 && digest40 .bytes()41 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),42 "config revision must be sha256 followed by 64 lowercase hexadecimal digits"43 );44 Ok(Self(value.to_owned()))45 }4647 pub fn as_str(&self) -> &str {48 &self.049 }50}5152impl<'de> Deserialize<'de> for ConfigRevision {53 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>54 where55 D: serde::Deserializer<'de>,56 {57 let value = String::deserialize(deserializer)?;58 Self::parse(&value).map_err(serde::de::Error::custom)59 }60}6162impl fmt::Display for ConfigRevision {63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {64 formatter.write_str(&self.0)65 }66}6768#[derive(Clone, Debug)]69pub struct LoadedHostConfig {70 pub config: HostConfig,71 pub bytes: Vec<u8>,72 pub revision: ConfigRevision,73}7475#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]76#[serde(rename_all = "kebab-case")]77pub enum VideoSource {78 TestPattern,79 GamescopePipewire,80}8182#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]83#[serde(rename_all = "kebab-case")]84pub enum InputMode {85 Log,86 Disabled,87 Uinput,88}8990#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]91#[serde(rename_all = "kebab-case")]92pub enum VaapiRateControl {93 Cbr,94 Cqp,95}9697#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]98#[serde(rename_all = "kebab-case")]99pub enum GamescopeEncoderBackend {100 /// The child-process pipeline. It cannot change encoder properties while101 /// playing, so adaptive bitrate and motion-sensitive resolution can only102 /// run in shadow. Retained for CQP and as an explicit escape hatch, and it103 /// is the default on builds that cannot run the in-process backend.104 #[cfg_attr(105 not(all(target_os = "linux", feature = "in-process-gstreamer")),106 default107 )]108 ExternalGstLaunch,109 /// Run the video pipeline in-process so Sigil can apply bounded encoder110 /// controls. This is the only backend where adaptive bitrate and111 /// resolution actuation are live, so it is the default wherever it can112 /// actually run. The default deliberately tracks build capability: a113 /// binary without this feature would otherwise reject its own default114 /// configuration.115 #[cfg_attr(all(target_os = "linux", feature = "in-process-gstreamer"), default)]116 InProcessGstreamer,117}118119fn default_framerate() -> u32 {120 60121}122123fn default_codec() -> String {124 "h264".to_owned()125}126127fn default_source() -> VideoSource {128 VideoSource::TestPattern129}130131fn default_input_mode() -> InputMode {132 InputMode::Disabled133}134135const fn default_max_viewers() -> u8 {136 DEFAULT_MAX_VIEWERS137}138139fn default_ffmpeg() -> PathBuf {140 PathBuf::from("ffmpeg")141}142143#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]144#[serde(deny_unknown_fields)]145pub struct GamescopePipewireConfig {146 /// Exact PipeWire `node.name` value to match.147 pub node_name: String,148 /// Exact PipeWire `media.class` value. This proof only accepts Video/Source.149 pub media_class: String,150 /// Optional additional exact PipeWire property matches, for example a stable GPU identity.151 #[serde(default)]152 pub match_properties: BTreeMap<String, String>,153 /// Bootstrap Gamescope Xwayland display. When Gamescope exposes its154 /// mouse-focus property Sigil follows the active Xwayland; otherwise155 /// pointer feedback remains bounded to this configured display. Absence156 /// disables cursor feedback.157 #[serde(default)]158 pub xwayland_display: Option<String>,159 /// Absolute paths avoid PATH-dependent executable selection in the daemon.160 pub pw_dump_path: PathBuf,161 pub gst_launch_path: PathBuf,162 pub gst_inspect_path: PathBuf,163 /// Defaults to the in-process backend on builds that contain its feature,164 /// because that is the only backend where adaptive bitrate and resolution165 /// actuation are live. Other builds default to external gst-launch.166 #[serde(default)]167 pub encoder_backend: GamescopeEncoderBackend,168 /// Exact dynamically registered VA encoder factory, such as `vah264enc`.169 pub vaapi_encoder: String,170 /// Exact AMD DRM render node expected to back `vaapi_encoder`.171 pub vaapi_render_node: PathBuf,172 /// Explicit mode must be advertised by the selected factory.173 pub rate_control: VaapiRateControl,174 /// Required only for CBR, in kilobits per second.175 pub bitrate_kbps: Option<u32>,176 /// Required only for CQP, applied to I and P frames.177 pub quantizer: Option<u8>,178}179180/// Exact PipeWire sink monitor used for optional game-audio capture.181#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]182#[serde(deny_unknown_fields)]183pub struct PipewireAudioConfig {184 /// Exact PipeWire `node.name` for the sink whose monitor is captured.185 pub node_name: String,186 /// V1 accepts only an Audio/Sink monitor, never a microphone source.187 pub media_class: String,188 #[serde(default)]189 pub match_properties: BTreeMap<String, String>,190 /// V1 is deliberately fixed at 96 kbit/s stereo Opus.191 pub bitrate_bps: u32,192}193194#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]195#[serde(deny_unknown_fields)]196pub struct UinputConfig {197 /// Explicit device node opened by the daemon. No symlinks are followed.198 pub device_path: PathBuf,199 /// Exact ownership and permission metadata expected on the opened device.200 pub expected_owner_uid: u32,201 pub expected_group_gid: u32,202 pub expected_mode: u32,203 /// Optional single named-user POSIX ACL entry expected for the daemon UID.204 ///205 /// Omission preserves the default group-only scheme and rejects every206 /// extended access ACL.207 #[serde(default)]208 pub expected_acl_user_uid: Option<u32>,209}210211impl UinputConfig {212 fn validate(&self) -> Result<()> {213 ensure!(214 self.device_path == Path::new("/dev/uinput")215 || self.device_path == Path::new("/dev/input/uinput"),216 "uinput.device_path must be /dev/uinput or /dev/input/uinput"217 );218 ensure!(219 self.expected_mode <= 0o777,220 "uinput.expected_mode must contain only Unix permission bits"221 );222 ensure!(223 self.expected_mode & 0o600 == 0o600,224 "uinput.expected_mode must grant owner read/write access"225 );226 ensure!(227 self.expected_mode & 0o111 == 0,228 "uinput.expected_mode must not contain execute bits"229 );230 ensure!(231 self.expected_mode & 0o007 == 0,232 "uinput.expected_mode must not grant access to other users"233 );234 if let Some(acl_uid) = self.expected_acl_user_uid {235 ensure!(236 acl_uid != self.expected_owner_uid,237 "uinput.expected_acl_user_uid must differ from expected_owner_uid"238 );239 ensure!(240 self.expected_mode & 0o060 == 0o060,241 "uinput.expected_mode must grant ACL mask read/write access when expected_acl_user_uid is set"242 );243 }244 Ok(())245 }246}247248impl GamescopePipewireConfig {249 fn validate(&self) -> Result<()> {250 validate_pipewire_property("node_name", &self.node_name)?;251 ensure!(252 self.media_class == "Video/Source",253 "gamescope_pipewire.media_class must be Video/Source"254 );255 for (key, value) in &self.match_properties {256 validate_pipewire_property("match_properties key", key)?;257 validate_pipewire_property("match_properties value", value)?;258 ensure!(259 key != "node.name" && key != "media.class" && key != "object.serial",260 "gamescope_pipewire.match_properties must not override {key}"261 );262 }263 if let Some(display) = &self.xwayland_display {264 let number = display.strip_prefix(':').unwrap_or_default();265 ensure!(266 !number.is_empty()267 && number.len() <= 3268 && number.bytes().all(|byte| byte.is_ascii_digit()),269 "gamescope_pipewire.xwayland_display must be : followed by 1 to 3 digits"270 );271 }272 for (name, path) in [273 ("pw_dump_path", &self.pw_dump_path),274 ("gst_launch_path", &self.gst_launch_path),275 ("gst_inspect_path", &self.gst_inspect_path),276 ("vaapi_render_node", &self.vaapi_render_node),277 ] {278 ensure!(279 path.is_absolute(),280 "gamescope_pipewire.{name} must be an absolute path"281 );282 }283 ensure!(284 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer285 || cfg!(all(target_os = "linux", feature = "in-process-gstreamer")),286 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"287 );288 // Not a temporary gap: the in-process pipeline is built around a289 // mutable encoder `bitrate` property, which constant-quantizer mode290 // does not have. The two are contradictory rather than unfinished.291 ensure!(292 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer293 || self.rate_control == VaapiRateControl::Cbr,294 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires \295 rate_control=\"cbr\", because adaptive bitrate changes a live \296 encoder bitrate that constant-quantizer mode does not expose. \297 Either set rate_control=\"cbr\" to keep adaptive bitrate and \298 motion-sensitive resolution, or set \299 encoder_backend=\"external-gst-launch\" to keep CQP and accept \300 that both adapt in shadow only."301 );302 ensure!(303 self.vaapi_render_node304 .file_name()305 .and_then(|name| name.to_str())306 .is_some_and(|name| {307 name.strip_prefix("renderD").is_some_and(|suffix| {308 !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())309 })310 }),311 "gamescope_pipewire.vaapi_render_node must name a DRM renderD device"312 );313 let generic_encoder = matches!(self.vaapi_encoder.as_str(), "vah264enc" | "vah264lpenc");314 let per_device_suffix = self315 .vaapi_encoder316 .strip_prefix("varenderD")317 .and_then(|name| {318 name.strip_suffix("h264enc")319 .or_else(|| name.strip_suffix("h264lpenc"))320 });321 ensure!(322 generic_encoder323 || per_device_suffix.is_some_and(|device| {324 !device.is_empty() && device.bytes().all(|byte| byte.is_ascii_digit())325 }),326 "gamescope_pipewire.vaapi_encoder must be a GstVA H.264 factory for normal or low-power encoding"327 );328 match self.rate_control {329 VaapiRateControl::Cbr => {330 ensure!(331 self.bitrate_kbps332 .is_some_and(|bitrate| (1_000..=100_000).contains(&bitrate)),333 "gamescope_pipewire.bitrate_kbps must be between 1000 and 100000 for CBR"334 );335 ensure!(336 self.quantizer.is_none(),337 "gamescope_pipewire.quantizer must be absent for CBR"338 );339 }340 VaapiRateControl::Cqp => {341 ensure!(342 self.quantizer343 .is_some_and(|quantizer| (1..=51).contains(&quantizer)),344 "gamescope_pipewire.quantizer must be between 1 and 51 for CQP"345 );346 ensure!(347 self.bitrate_kbps.is_none(),348 "gamescope_pipewire.bitrate_kbps must be absent for CQP"349 );350 }351 }352 Ok(())353 }354}355356impl PipewireAudioConfig {357 fn validate(&self) -> Result<()> {358 validate_pipewire_property("audio.node_name", &self.node_name)?;359 ensure!(360 self.media_class == "Audio/Sink",361 "audio.media_class must be Audio/Sink"362 );363 for (key, value) in &self.match_properties {364 validate_pipewire_property("audio.match_properties key", key)?;365 validate_pipewire_property("audio.match_properties value", value)?;366 ensure!(367 key != "node.name" && key != "media.class" && key != "object.serial",368 "audio.match_properties must not override {key}"369 );370 }371 ensure!(372 self.bitrate_bps == 96_000,373 "audio.bitrate_bps must be 96000 for the v1 Opus target"374 );375 Ok(())376 }377}378379fn validate_pipewire_property(name: &str, value: &str) -> Result<()> {380 ensure!(381 !value.is_empty() && value.len() <= 512 && !value.contains('\0'),382 "gamescope_pipewire.{name} must contain 1 to 512 non-NUL bytes"383 );384 Ok(())385}386387#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]388#[serde(deny_unknown_fields)]389pub struct HostConfig {390 pub identity_path: PathBuf,391 pub state_path: PathBuf,392 /// Concurrent native-MoQ v2 viewers. Durable enrollment has a separate393 /// fixed bound and legacy transports remain exclusive.394 #[serde(default = "default_max_viewers")]395 pub max_viewers: u8,396 /// Optional stable opaque authorization handle allowed to preempt slot 0.397 /// Other viewers can request a holder-approved handoff but cannot preempt.398 #[serde(default)]399 pub focus_owner: Option<String>,400 #[serde(default = "default_source")]401 pub source: VideoSource,402 /// Optional encoded-size override. Gamescope uses its advertised native403 /// size when both fields are absent; the test-pattern proof retains its404 /// 1280x800 fixture default.405 #[serde(default, skip_serializing_if = "Option::is_none")]406 pub width: Option<u32>,407 #[serde(default, skip_serializing_if = "Option::is_none")]408 pub height: Option<u32>,409 #[serde(default = "default_framerate")]410 pub framerate: u32,411 #[serde(default = "default_codec")]412 pub codec: String,413 #[serde(default = "default_input_mode")]414 pub input_mode: InputMode,415 /// Required only when input_mode is uinput.416 #[serde(default)]417 pub uinput: Option<UinputConfig>,418 #[serde(default = "default_ffmpeg")]419 pub ffmpeg_path: PathBuf,420 /// Required only for the production Gamescope PipeWire source.421 #[serde(default)]422 pub gamescope_pipewire: Option<GamescopePipewireConfig>,423 /// Optional exact PipeWire sink monitor. Absence keeps the host video-only.424 #[serde(default)]425 pub audio: Option<PipewireAudioConfig>,426}427428impl HostConfig {429 pub const TEST_PATTERN_WIDTH: u32 = 1_280;430 pub const TEST_PATTERN_HEIGHT: u32 = 800;431432 pub fn load(path: &Path) -> Result<Self> {433 Ok(Self::load_document(path)?.config)434 }435436 pub fn load_document(path: &Path) -> Result<LoadedHostConfig> {437 let parent = path438 .parent()439 .filter(|parent| !parent.as_os_str().is_empty())440 .unwrap_or(Path::new("."));441 crate::secure_state::validate_integrity_protected_directory(parent).with_context(|| {442 format!(443 "validating config directory {} before loading {}",444 parent.display(),445 path.display()446 )447 })?;448 let mut options = OpenOptions::new();449 options.read(true);450 #[cfg(unix)]451 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);452 let mut file = options.open(path).with_context(|| {453 format!(454 "opening config {} without following symlinks",455 path.display()456 )457 })?;458 let metadata = file459 .metadata()460 .with_context(|| format!("inspecting opened config {}", path.display()))?;461 validate_file_security(path, &metadata)?;462 ensure!(463 metadata.len() <= MAX_CONFIG_BYTES,464 "config exceeds its fixed size bound"465 );466 let mut bytes = Vec::with_capacity(metadata.len() as usize);467 Read::by_ref(&mut file)468 .take(MAX_CONFIG_BYTES.saturating_add(1))469 .read_to_end(&mut bytes)470 .with_context(|| format!("reading config {}", path.display()))?;471 ensure!(472 bytes.len() as u64 <= MAX_CONFIG_BYTES,473 "config exceeds its fixed size bound"474 );475 let config =476 Self::parse(&bytes).with_context(|| format!("parsing config {}", path.display()))?;477 let revision = ConfigRevision::from_bytes(&bytes);478 Ok(LoadedHostConfig {479 config,480 bytes,481 revision,482 })483 }484485 pub fn parse(bytes: &[u8]) -> Result<Self> {486 ensure!(487 bytes.len() as u64 <= MAX_CONFIG_BYTES,488 "config exceeds its fixed size bound"489 );490 let contents = std::str::from_utf8(bytes).context("config is not valid UTF-8")?;491 let config: Self = toml::from_str(contents).context("parsing config TOML")?;492 config.validate()?;493 Ok(config)494 }495496 pub fn validate(&self) -> Result<()> {497 ensure!(498 !self.identity_path.as_os_str().is_empty(),499 "identity_path is required"500 );501 ensure!(502 !self.state_path.as_os_str().is_empty(),503 "state_path is required"504 );505 ensure!(506 (1..=MAX_VIEWERS).contains(&self.max_viewers),507 "max_viewers must be between 1 and {MAX_VIEWERS}"508 );509 if let Some(handle) = &self.focus_owner {510 ensure!(511 handle.len() == 23512 && handle.starts_with("viewer-")513 && handle["viewer-".len()..]514 .bytes()515 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),516 "focus_owner must be a stable opaque viewer- followed by 16 lowercase hexadecimal digits"517 );518 }519 match (self.width, self.height) {520 (Some(width), Some(height)) => validate_video_dimensions(width, height)?,521 (None, None) => {}522 _ => anyhow::bail!("width and height must either both be set or both be absent"),523 }524 ensure!(525 (1..=240).contains(&self.framerate),526 "framerate must be between 1 and 240"527 );528 ensure!(529 self.codec == "h264",530 "only h264 is supported during the first proof"531 );532 ensure!(533 !self.ffmpeg_path.as_os_str().is_empty(),534 "ffmpeg_path is required"535 );536 match (&self.input_mode, &self.uinput) {537 (InputMode::Uinput, Some(config)) => config.validate()?,538 (InputMode::Uinput, None) => {539 anyhow::bail!("uinput configuration is required when input_mode is uinput");540 }541 (InputMode::Log | InputMode::Disabled, None) => {}542 (InputMode::Log | InputMode::Disabled, Some(_)) => {543 anyhow::bail!("uinput configuration must be absent unless input_mode is uinput");544 }545 }546 match (&self.source, &self.gamescope_pipewire) {547 (VideoSource::TestPattern, None) => {}548 (VideoSource::TestPattern, Some(_)) => {549 anyhow::bail!("gamescope_pipewire must be absent when source is test-pattern");550 }551 (VideoSource::GamescopePipewire, Some(config)) => config.validate()?,552 (VideoSource::GamescopePipewire, None) => {553 anyhow::bail!(554 "gamescope_pipewire configuration is required when source is gamescope-pipewire"555 );556 }557 }558 match (&self.source, &self.gamescope_pipewire, &self.audio) {559 (_, _, None) => {}560 (VideoSource::GamescopePipewire, Some(_), Some(audio)) => audio.validate()?,561 _ => anyhow::bail!(562 "audio requires source=gamescope-pipewire and gamescope_pipewire configuration"563 ),564 }565 Ok(())566 }567568 pub fn configured_dimensions(&self) -> Option<(u32, u32)> {569 self.width.zip(self.height)570 }571572 pub fn test_pattern_dimensions(&self) -> Result<(u32, u32)> {573 ensure!(574 self.source == VideoSource::TestPattern,575 "test-pattern dimensions requested for a non-test source"576 );577 Ok(self578 .configured_dimensions()579 .unwrap_or((Self::TEST_PATTERN_WIDTH, Self::TEST_PATTERN_HEIGHT)))580 }581582 pub fn ensure_runtime_directory(&self) -> Result<()> {583 if self.state_path.exists() {584 let metadata = fs::symlink_metadata(&self.state_path)585 .with_context(|| format!("inspecting state_path {}", self.state_path.display()))?;586 ensure!(587 !metadata.file_type().is_symlink(),588 "state_path must not be a symlink"589 );590 ensure!(metadata.is_dir(), "state_path must be a directory");591 #[cfg(unix)]592 {593 ensure!(594 metadata.mode() & 0o077 == 0,595 "state_path {} must not be accessible by group or other users",596 self.state_path.display()597 );598 ensure!(599 metadata.uid() == unsafe { libc::geteuid() },600 "state_path {} is not owned by the current user",601 self.state_path.display()602 );603 }604 } else {605 #[cfg(unix)]606 {607 use std::os::unix::fs::DirBuilderExt;608 let mut builder = fs::DirBuilder::new();609 builder610 .recursive(true)611 .mode(0o700)612 .create(&self.state_path)?;613 }614 #[cfg(not(unix))]615 fs::create_dir_all(&self.state_path)?;616 }617 Ok(())618 }619}620621fn validate_video_dimensions(width: u32, height: u32) -> Result<()> {622 ensure!(623 (64..=7680).contains(&width),624 "width must be between 64 and 7680"625 );626 ensure!(627 (64..=4320).contains(&height),628 "height must be between 64 and 4320"629 );630 ensure!(631 width.is_multiple_of(2) && height.is_multiple_of(2),632 "H.264 dimensions must be even"633 );634 Ok(())635}636637fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {638 ensure!(metadata.is_file(), "config must be a regular file");639 #[cfg(unix)]640 {641 ensure!(642 metadata.permissions().mode() & 0o022 == 0,643 "config {} must not be writable by group or other users",644 path.display()645 );646 ensure!(647 metadata.uid() == unsafe { libc::geteuid() },648 "config {} is not owned by the current user",649 path.display()650 );651 }652 Ok(())653}654655#[cfg(test)]656mod tests {657 use super::*;658659 /// The compiled default tracks build capability, so tests must expect the660 /// backend this binary can actually run rather than a fixed variant.661 fn default_encoder_backend() -> GamescopeEncoderBackend {662 if cfg!(all(target_os = "linux", feature = "in-process-gstreamer")) {663 GamescopeEncoderBackend::InProcessGstreamer664 } else {665 GamescopeEncoderBackend::ExternalGstLaunch666 }667 }668669 #[test]670 fn config_revision_is_exact_bounded_and_strict() {671 let first = ConfigRevision::from_bytes(b"a = 1\n");672 let second = ConfigRevision::from_bytes(b"a = 1\n\n");673 assert_ne!(first, second);674 assert_eq!(ConfigRevision::parse(first.as_str()).unwrap(), first);675 assert!(ConfigRevision::parse("sha256:ABC").is_err());676 assert!(ConfigRevision::parse("md5:00000000000000000000000000000000").is_err());677 }678679 #[test]680 fn load_document_hashes_the_same_bounded_bytes_it_parses() {681 let directory = tempfile::tempdir().unwrap();682 let path = directory.path().join("host.toml");683 let bytes = b"identity_path = \"/tmp/host.key\"\nstate_path = \"/tmp/state\"\n";684 fs::write(&path, bytes).unwrap();685 let loaded = HostConfig::load_document(&path).unwrap();686 assert_eq!(loaded.bytes, bytes);687 assert_eq!(loaded.revision, ConfigRevision::from_bytes(bytes));688689 fs::write(&path, vec![b' '; MAX_CONFIG_BYTES as usize + 1]).unwrap();690 assert!(HostConfig::load_document(&path).is_err());691 }692693 #[cfg(unix)]694 #[test]695 fn config_directory_protects_integrity_without_requiring_confidentiality() {696 use std::os::unix::fs::symlink;697698 let outer = tempfile::tempdir().unwrap();699 let directory = outer.path().join("config");700 fs::create_dir(&directory).unwrap();701 fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();702 let path = directory.join("host.toml");703 fs::write(704 &path,705 b"identity_path = \"/tmp/host.key\"\nstate_path = \"/tmp/state\"\n",706 )707 .unwrap();708 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();709 HostConfig::load_document(&path).unwrap();710711 fs::set_permissions(&directory, fs::Permissions::from_mode(0o775)).unwrap();712 assert!(HostConfig::load_document(&path).is_err());713 fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();714715 let linked_directory = outer.path().join("linked-config");716 symlink(&directory, &linked_directory).unwrap();717 assert!(HostConfig::load_document(&linked_directory.join("host.toml")).is_err());718 }719720 #[test]721 fn rejects_unknown_fields() {722 let input = r#"723identity_path = "/tmp/host.key"724state_path = "/tmp/state"725surprise = true726"#;727 assert!(toml::from_str::<HostConfig>(input).is_err());728 }729730 #[test]731 fn validates_first_target() {732 let input = r#"733identity_path = "/tmp/host.key"734state_path = "/tmp/state"735source = "test-pattern"736width = 1280737height = 800738framerate = 60739codec = "h264"740"#;741 let config: HostConfig = toml::from_str(input).unwrap();742 config.validate().unwrap();743 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));744 }745746 #[test]747 fn test_pattern_omission_retains_the_proof_fixture() {748 let config: HostConfig = toml::from_str(749 r#"750identity_path = "/tmp/host.key"751state_path = "/tmp/state"752"#,753 )754 .unwrap();755 config.validate().unwrap();756 assert_eq!(config.max_viewers, DEFAULT_MAX_VIEWERS);757 assert_eq!(config.configured_dimensions(), None);758 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));759 }760761 #[test]762 fn max_viewers_defaults_to_three_and_rejects_values_outside_one_through_eight() {763 let mut config: HostConfig = toml::from_str(764 r#"765identity_path = "/tmp/host.key"766state_path = "/tmp/state"767"#,768 )769 .unwrap();770 assert_eq!(config.max_viewers, 3);771 config.validate().unwrap();772773 config.max_viewers = 1;774 config.validate().unwrap();775 config.max_viewers = 8;776 config.validate().unwrap();777 config.max_viewers = 0;778 assert!(config.validate().is_err());779 config.max_viewers = 9;780 assert!(config.validate().is_err());781 }782783 #[test]784 fn focus_owner_accepts_only_stable_opaque_authorization_handles() {785 let mut config: HostConfig = toml::from_str(786 r#"787identity_path = "/tmp/host.key"788state_path = "/tmp/state"789focus_owner = "viewer-0123456789abcdef"790"#,791 )792 .unwrap();793 config.validate().unwrap();794795 config.focus_owner = Some("viewer-owner".into());796 assert!(config.validate().is_err());797 config.focus_owner = Some("viewer-0123456789ABCDEF".into());798 assert!(config.validate().is_err());799 }800801 #[test]802 fn validates_strict_gamescope_pipewire_configuration() {803 let input = r#"804identity_path = "/tmp/host.key"805state_path = "/tmp/state"806source = "gamescope-pipewire"807framerate = 60808codec = "h264"809810[gamescope_pipewire]811node_name = "gamescope"812media_class = "Video/Source"813xwayland_display = ":0"814pw_dump_path = "/usr/bin/pw-dump"815gst_launch_path = "/usr/bin/gst-launch-1.0"816gst_inspect_path = "/usr/bin/gst-inspect-1.0"817encoder_backend = "external-gst-launch"818vaapi_encoder = "vah264enc"819vaapi_render_node = "/dev/dri/renderD128"820rate_control = "cbr"821bitrate_kbps = 12000822823[gamescope_pipewire.match_properties]824"device.bus-path" = "pci-0000:04:00.0"825826[audio]827node_name = "sigil-game-audio"828media_class = "Audio/Sink"829bitrate_bps = 96000830831[audio.match_properties]832"device.profile.name" = "stereo"833"#;834 let config: HostConfig = toml::from_str(input).unwrap();835 config.validate().unwrap();836 assert_eq!(config.configured_dimensions(), None);837 assert_eq!(838 config839 .gamescope_pipewire840 .as_ref()841 .unwrap()842 .xwayland_display843 .as_deref(),844 Some(":0")845 );846 assert_eq!(847 config.gamescope_pipewire.as_ref().unwrap().encoder_backend,848 GamescopeEncoderBackend::ExternalGstLaunch849 );850 }851852 #[test]853 fn audio_is_optional_and_strictly_tied_to_gamescope() {854 let mut config: HostConfig = toml::from_str(855 r#"856identity_path = "/tmp/host.key"857state_path = "/tmp/state"858source = "gamescope-pipewire"859860[gamescope_pipewire]861node_name = "gamescope"862media_class = "Video/Source"863pw_dump_path = "/usr/bin/pw-dump"864gst_launch_path = "/usr/bin/gst-launch-1.0"865gst_inspect_path = "/usr/bin/gst-inspect-1.0"866vaapi_encoder = "vah264enc"867vaapi_render_node = "/dev/dri/renderD128"868rate_control = "cbr"869bitrate_kbps = 12000870871[audio]872node_name = "sigil-game-audio"873media_class = "Audio/Sink"874bitrate_bps = 96000875"#,876 )877 .unwrap();878 config.validate().unwrap();879880 config.source = VideoSource::TestPattern;881 assert!(config.validate().is_err());882 config.source = VideoSource::GamescopePipewire;883884 config.audio.as_mut().unwrap().media_class = "Audio/Source".into();885 assert!(config.validate().is_err());886 config.audio.as_mut().unwrap().media_class = "Audio/Sink".into();887 config.audio.as_mut().unwrap().bitrate_bps = 128_000;888 assert!(config.validate().is_err());889 config.audio.as_mut().unwrap().bitrate_bps = 96_000;890 config891 .audio892 .as_mut()893 .unwrap()894 .match_properties895 .insert("object.serial".into(), "50".into());896 assert!(config.validate().is_err());897 }898899 #[test]900 fn gamescope_configuration_fails_closed() {901 let mut config: HostConfig = toml::from_str(902 r#"903identity_path = "/tmp/host.key"904state_path = "/tmp/state"905source = "gamescope-pipewire"906"#,907 )908 .unwrap();909 assert!(config.validate().is_err());910911 config.source = VideoSource::TestPattern;912 config.gamescope_pipewire = Some(GamescopePipewireConfig {913 xwayland_display: None,914 node_name: "gamescope".into(),915 media_class: "Video/Source".into(),916 match_properties: BTreeMap::new(),917 pw_dump_path: "/usr/bin/pw-dump".into(),918 gst_launch_path: "/usr/bin/gst-launch-1.0".into(),919 gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),920 encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,921 vaapi_encoder: "vah264enc".into(),922 vaapi_render_node: "/dev/dri/renderD128".into(),923 rate_control: VaapiRateControl::Cbr,924 bitrate_kbps: Some(12_000),925 quantizer: None,926 });927 assert!(config.validate().is_err());928929 config.source = VideoSource::GamescopePipewire;930 config.gamescope_pipewire.as_mut().unwrap().xwayland_display = Some("localhost:0".into());931 assert!(config.validate().is_err());932 config.gamescope_pipewire.as_mut().unwrap().xwayland_display = Some(":0".into());933 config.validate().unwrap();934 config.gamescope_pipewire.as_mut().unwrap().vaapi_encoder = "x264enc".into();935 assert!(config.validate().is_err());936 }937938 #[test]939 fn gamescope_encoder_backend_defaults_to_build_capability() {940 let config: HostConfig = toml::from_str(941 r#"942identity_path = "/tmp/host.key"943state_path = "/tmp/state"944source = "gamescope-pipewire"945946[gamescope_pipewire]947node_name = "gamescope"948media_class = "Video/Source"949pw_dump_path = "/usr/bin/pw-dump"950gst_launch_path = "/usr/bin/gst-launch-1.0"951gst_inspect_path = "/usr/bin/gst-inspect-1.0"952vaapi_encoder = "vah264enc"953vaapi_render_node = "/dev/dri/renderD128"954rate_control = "cbr"955bitrate_kbps = 12000956"#,957 )958 .unwrap();959960 assert_eq!(961 config.gamescope_pipewire.unwrap().encoder_backend,962 default_encoder_backend()963 );964 }965966 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]967 #[test]968 fn unavailable_in_process_backend_fails_closed() {969 let mut config: HostConfig = toml::from_str(970 r#"971identity_path = "/tmp/host.key"972state_path = "/tmp/state"973source = "gamescope-pipewire"974975[gamescope_pipewire]976node_name = "gamescope"977media_class = "Video/Source"978pw_dump_path = "/usr/bin/pw-dump"979gst_launch_path = "/usr/bin/gst-launch-1.0"980gst_inspect_path = "/usr/bin/gst-inspect-1.0"981vaapi_encoder = "vah264enc"982vaapi_render_node = "/dev/dri/renderD128"983rate_control = "cbr"984bitrate_kbps = 12000985"#,986 )987 .unwrap();988 config.gamescope_pipewire.as_mut().unwrap().encoder_backend =989 GamescopeEncoderBackend::InProcessGstreamer;990991 assert!(config.validate().is_err());992 }993994 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]995 #[test]996 fn feature_built_linux_accepts_in_process_backend() {997 let mut config: HostConfig = toml::from_str(998 r#"999identity_path = "/tmp/host.key"1000state_path = "/tmp/state"1001source = "gamescope-pipewire"10021003[gamescope_pipewire]1004node_name = "gamescope"1005media_class = "Video/Source"1006pw_dump_path = "/usr/bin/pw-dump"1007gst_launch_path = "/usr/bin/gst-launch-1.0"1008gst_inspect_path = "/usr/bin/gst-inspect-1.0"1009vaapi_encoder = "vah264enc"1010vaapi_render_node = "/dev/dri/renderD128"1011rate_control = "cbr"1012bitrate_kbps = 120001013"#,1014 )1015 .unwrap();1016 let pipewire = config.gamescope_pipewire.as_mut().unwrap();1017 pipewire.encoder_backend = GamescopeEncoderBackend::InProcessGstreamer;10181019 config.validate().unwrap();1020 let pipewire = config.gamescope_pipewire.unwrap();1021 assert_eq!(1022 pipewire.encoder_backend,1023 GamescopeEncoderBackend::InProcessGstreamer1024 );1025 assert_eq!(pipewire.rate_control, VaapiRateControl::Cbr);1026 }10271028 #[test]1029 fn in_process_cqp_fails_closed() {1030 let pipewire = GamescopePipewireConfig {1031 node_name: "gamescope".into(),1032 media_class: "Video/Source".into(),1033 match_properties: BTreeMap::new(),1034 xwayland_display: None,1035 pw_dump_path: "/usr/bin/pw-dump".into(),1036 gst_launch_path: "/usr/bin/gst-launch-1.0".into(),1037 gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),1038 encoder_backend: GamescopeEncoderBackend::InProcessGstreamer,1039 vaapi_encoder: "vah264enc".into(),1040 vaapi_render_node: "/dev/dri/renderD128".into(),1041 rate_control: VaapiRateControl::Cqp,1042 bitrate_kbps: None,1043 quantizer: Some(24),1044 };10451046 assert!(pipewire.validate().is_err());1047 }10481049 #[test]1050 fn rejects_odd_dimensions_and_codec_breadth() {1051 let mut config: HostConfig = toml::from_str(1052 r#"1053identity_path = "/tmp/host.key"1054state_path = "/tmp/state"1055"#,1056 )1057 .unwrap();1058 config.width = Some(1279);1059 assert!(config.validate().is_err());1060 config.width = Some(1280);1061 config.codec = "av1".into();1062 assert!(config.validate().is_err());1063 }10641065 #[test]1066 fn rejects_partial_dimension_override() {1067 let config: HostConfig = toml::from_str(1068 r#"1069identity_path = "/tmp/host.key"1070state_path = "/tmp/state"1071width = 12801072"#,1073 )1074 .unwrap();1075 assert!(1076 config1077 .validate()1078 .unwrap_err()1079 .to_string()1080 .contains("width and height must either both be set or both be absent")1081 );1082 }10831084 #[test]1085 fn validates_strict_uinput_configuration() {1086 let config: HostConfig = toml::from_str(1087 r#"1088identity_path = "/tmp/host.key"1089state_path = "/tmp/state"1090input_mode = "uinput"10911092[uinput]1093device_path = "/dev/uinput"1094expected_owner_uid = 01095expected_group_gid = 9861096expected_mode = 0o6601097"#,1098 )1099 .unwrap();1100 config.validate().unwrap();1101 assert_eq!(config.uinput.as_ref().unwrap().expected_acl_user_uid, None);11021103 let acl_config: HostConfig = toml::from_str(1104 r#"1105identity_path = "/tmp/host.key"1106state_path = "/tmp/state"1107input_mode = "uinput"11081109[uinput]1110device_path = "/dev/uinput"1111expected_owner_uid = 01112expected_group_gid = 9861113expected_mode = 0o6601114expected_acl_user_uid = 10001115"#,1116 )1117 .unwrap();1118 acl_config.validate().unwrap();1119 assert_eq!(1120 acl_config.uinput.as_ref().unwrap().expected_acl_user_uid,1121 Some(1000)1122 );1123 }11241125 #[test]1126 fn uinput_configuration_fails_closed() {1127 let mut config: HostConfig = toml::from_str(1128 r#"1129identity_path = "/tmp/host.key"1130state_path = "/tmp/state"1131input_mode = "uinput"11321133[uinput]1134device_path = "/tmp/uinput"1135expected_owner_uid = 01136expected_group_gid = 9861137expected_mode = 0o6661138"#,1139 )1140 .unwrap();1141 assert!(config.validate().is_err());11421143 config.uinput.as_mut().unwrap().device_path = "/dev/uinput".into();1144 assert!(config.validate().is_err());11451146 config.uinput.as_mut().unwrap().expected_mode = 0o660;1147 config.input_mode = InputMode::Disabled;1148 assert!(config.validate().is_err());1149 }11501151 #[test]1152 fn explicit_uinput_acl_principal_fails_closed() {1153 let mut config: HostConfig = toml::from_str(1154 r#"1155identity_path = "/tmp/host.key"1156state_path = "/tmp/state"1157input_mode = "uinput"11581159[uinput]1160device_path = "/dev/uinput"1161expected_owner_uid = 01162expected_group_gid = 9861163expected_mode = 0o6601164expected_acl_user_uid = 10001165"#,1166 )1167 .unwrap();11681169 config.uinput.as_mut().unwrap().expected_acl_user_uid = Some(0);1170 assert!(1171 config1172 .validate()1173 .unwrap_err()1174 .to_string()1175 .contains("must differ from expected_owner_uid")1176 );11771178 let uinput = config.uinput.as_mut().unwrap();1179 uinput.expected_acl_user_uid = Some(1000);1180 uinput.expected_mode = 0o640;1181 assert!(1182 config1183 .validate()1184 .unwrap_err()1185 .to_string()1186 .contains("ACL mask read/write")1187 );1188 }11891190 #[cfg(unix)]1191 #[test]1192 fn rejects_symlink_and_permissive_runtime_directories() {1193 use std::os::unix::fs::symlink;11941195 let temp = tempfile::tempdir().unwrap();1196 let target = temp.path().join("target");1197 fs::create_dir(&target).unwrap();1198 fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap();1199 let link = temp.path().join("link");1200 symlink(&target, &link).unwrap();12011202 let mut config: HostConfig = toml::from_str(1203 r#"1204identity_path = "/tmp/host.key"1205state_path = "/tmp/state"1206"#,1207 )1208 .unwrap();1209 config.state_path = link;1210 assert!(config.ensure_runtime_directory().is_err());12111212 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();1213 config.state_path = target;1214 assert!(config.ensure_runtime_directory().is_err());1215 }1216}