cat / goq
clone

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

fix/architecture-review-findings
goq / crates / sigil-host / src / config.rs
config.rs 36.6K
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;15const REVISION_PREFIX: &str = "sha256:";1617#[derive(Clone, Debug, PartialEq, Eq, Serialize)]18#[serde(transparent)]19pub struct ConfigRevision(String);2021impl ConfigRevision {22 pub fn from_bytes(bytes: &[u8]) -> Self {23 let digest = Sha256::digest(bytes);24 let mut value = String::with_capacity(REVISION_PREFIX.len() + digest.len() * 2);25 value.push_str(REVISION_PREFIX);26 for byte in digest {27 use fmt::Write as _;28 write!(&mut value, "{byte:02x}").expect("writing into a String cannot fail");29 }30 Self(value)31 }3233 pub fn parse(value: &str) -> Result<Self> {34 let digest = value.strip_prefix(REVISION_PREFIX).unwrap_or_default();35 ensure!(36 digest.len() == 6437 && digest38 .bytes()39 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),40 "config revision must be sha256 followed by 64 lowercase hexadecimal digits"41 );42 Ok(Self(value.to_owned()))43 }4445 pub fn as_str(&self) -> &str {46 &self.047 }48}4950impl<'de> Deserialize<'de> for ConfigRevision {51 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>52 where53 D: serde::Deserializer<'de>,54 {55 let value = String::deserialize(deserializer)?;56 Self::parse(&value).map_err(serde::de::Error::custom)57 }58}5960impl fmt::Display for ConfigRevision {61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {62 formatter.write_str(&self.0)63 }64}6566#[derive(Clone, Debug)]67pub struct LoadedHostConfig {68 pub config: HostConfig,69 pub bytes: Vec<u8>,70 pub revision: ConfigRevision,71}7273#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]74#[serde(rename_all = "kebab-case")]75pub enum VideoSource {76 TestPattern,77 GamescopePipewire,78}7980#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]81#[serde(rename_all = "kebab-case")]82pub enum InputMode {83 Log,84 Disabled,85 Uinput,86}8788#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]89#[serde(rename_all = "kebab-case")]90pub enum VaapiRateControl {91 Cbr,92 Cqp,93}9495#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]96#[serde(rename_all = "kebab-case")]97pub enum GamescopeEncoderBackend {98 /// Preserve the proven child-process pipeline and its natural-IDR control99 /// limitation for existing configurations.100 #[default]101 ExternalGstLaunch,102 /// Run the video pipeline in-process so Sigil can apply bounded encoder103 /// controls. This remains an explicit opt-in until hardware acceptance.104 InProcessGstreamer,105}106107fn default_framerate() -> u32 {108 60109}110111fn default_codec() -> String {112 "h264".to_owned()113}114115fn default_source() -> VideoSource {116 VideoSource::TestPattern117}118119fn default_input_mode() -> InputMode {120 InputMode::Disabled121}122123fn default_ffmpeg() -> PathBuf {124 PathBuf::from("ffmpeg")125}126127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]128#[serde(deny_unknown_fields)]129pub struct GamescopePipewireConfig {130 /// Exact PipeWire `node.name` value to match.131 pub node_name: String,132 /// Exact PipeWire `media.class` value. This proof only accepts Video/Source.133 pub media_class: String,134 /// Optional additional exact PipeWire property matches, for example a stable GPU identity.135 #[serde(default)]136 pub match_properties: BTreeMap<String, String>,137 /// Bootstrap Gamescope Xwayland display. When Gamescope exposes its138 /// mouse-focus property Sigil follows the active Xwayland; otherwise139 /// pointer feedback remains bounded to this configured display. Absence140 /// disables cursor feedback.141 #[serde(default)]142 pub xwayland_display: Option<String>,143 /// Absolute paths avoid PATH-dependent executable selection in the daemon.144 pub pw_dump_path: PathBuf,145 pub gst_launch_path: PathBuf,146 pub gst_inspect_path: PathBuf,147 /// External gst-launch remains the compatibility default. The in-process148 /// backend is accepted only by Linux builds that contain its feature.149 #[serde(default)]150 pub encoder_backend: GamescopeEncoderBackend,151 /// Exact dynamically registered VA encoder factory, such as `vah264enc`.152 pub vaapi_encoder: String,153 /// Exact AMD DRM render node expected to back `vaapi_encoder`.154 pub vaapi_render_node: PathBuf,155 /// Explicit mode must be advertised by the selected factory.156 pub rate_control: VaapiRateControl,157 /// Required only for CBR, in kilobits per second.158 pub bitrate_kbps: Option<u32>,159 /// Required only for CQP, applied to I and P frames.160 pub quantizer: Option<u8>,161}162163/// Exact PipeWire sink monitor used for optional game-audio capture.164#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]165#[serde(deny_unknown_fields)]166pub struct PipewireAudioConfig {167 /// Exact PipeWire `node.name` for the sink whose monitor is captured.168 pub node_name: String,169 /// V1 accepts only an Audio/Sink monitor, never a microphone source.170 pub media_class: String,171 #[serde(default)]172 pub match_properties: BTreeMap<String, String>,173 /// V1 is deliberately fixed at 96 kbit/s stereo Opus.174 pub bitrate_bps: u32,175}176177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]178#[serde(deny_unknown_fields)]179pub struct UinputConfig {180 /// Explicit device node opened by the daemon. No symlinks are followed.181 pub device_path: PathBuf,182 /// Exact ownership and permission metadata expected on the opened device.183 pub expected_owner_uid: u32,184 pub expected_group_gid: u32,185 pub expected_mode: u32,186 /// Optional single named-user POSIX ACL entry expected for the daemon UID.187 ///188 /// Omission preserves the default group-only scheme and rejects every189 /// extended access ACL.190 #[serde(default)]191 pub expected_acl_user_uid: Option<u32>,192}193194impl UinputConfig {195 fn validate(&self) -> Result<()> {196 ensure!(197 self.device_path == Path::new("/dev/uinput")198 || self.device_path == Path::new("/dev/input/uinput"),199 "uinput.device_path must be /dev/uinput or /dev/input/uinput"200 );201 ensure!(202 self.expected_mode <= 0o777,203 "uinput.expected_mode must contain only Unix permission bits"204 );205 ensure!(206 self.expected_mode & 0o600 == 0o600,207 "uinput.expected_mode must grant owner read/write access"208 );209 ensure!(210 self.expected_mode & 0o111 == 0,211 "uinput.expected_mode must not contain execute bits"212 );213 ensure!(214 self.expected_mode & 0o007 == 0,215 "uinput.expected_mode must not grant access to other users"216 );217 if let Some(acl_uid) = self.expected_acl_user_uid {218 ensure!(219 acl_uid != self.expected_owner_uid,220 "uinput.expected_acl_user_uid must differ from expected_owner_uid"221 );222 ensure!(223 self.expected_mode & 0o060 == 0o060,224 "uinput.expected_mode must grant ACL mask read/write access when expected_acl_user_uid is set"225 );226 }227 Ok(())228 }229}230231impl GamescopePipewireConfig {232 fn validate(&self) -> Result<()> {233 validate_pipewire_property("node_name", &self.node_name)?;234 ensure!(235 self.media_class == "Video/Source",236 "gamescope_pipewire.media_class must be Video/Source"237 );238 for (key, value) in &self.match_properties {239 validate_pipewire_property("match_properties key", key)?;240 validate_pipewire_property("match_properties value", value)?;241 ensure!(242 key != "node.name" && key != "media.class" && key != "object.serial",243 "gamescope_pipewire.match_properties must not override {key}"244 );245 }246 if let Some(display) = &self.xwayland_display {247 let number = display.strip_prefix(':').unwrap_or_default();248 ensure!(249 !number.is_empty()250 && number.len() <= 3251 && number.bytes().all(|byte| byte.is_ascii_digit()),252 "gamescope_pipewire.xwayland_display must be : followed by 1 to 3 digits"253 );254 }255 for (name, path) in [256 ("pw_dump_path", &self.pw_dump_path),257 ("gst_launch_path", &self.gst_launch_path),258 ("gst_inspect_path", &self.gst_inspect_path),259 ("vaapi_render_node", &self.vaapi_render_node),260 ] {261 ensure!(262 path.is_absolute(),263 "gamescope_pipewire.{name} must be an absolute path"264 );265 }266 ensure!(267 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer268 || cfg!(all(target_os = "linux", feature = "in-process-gstreamer")),269 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"270 );271 ensure!(272 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer273 || self.rate_control == VaapiRateControl::Cbr,274 "gamescope_pipewire.encoder_backend=in-process-gstreamer currently requires CBR"275 );276 ensure!(277 self.vaapi_render_node278 .file_name()279 .and_then(|name| name.to_str())280 .is_some_and(|name| {281 name.strip_prefix("renderD").is_some_and(|suffix| {282 !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())283 })284 }),285 "gamescope_pipewire.vaapi_render_node must name a DRM renderD device"286 );287 let generic_encoder = matches!(self.vaapi_encoder.as_str(), "vah264enc" | "vah264lpenc");288 let per_device_suffix = self289 .vaapi_encoder290 .strip_prefix("varenderD")291 .and_then(|name| {292 name.strip_suffix("h264enc")293 .or_else(|| name.strip_suffix("h264lpenc"))294 });295 ensure!(296 generic_encoder297 || per_device_suffix.is_some_and(|device| {298 !device.is_empty() && device.bytes().all(|byte| byte.is_ascii_digit())299 }),300 "gamescope_pipewire.vaapi_encoder must be a GstVA H.264 factory for normal or low-power encoding"301 );302 match self.rate_control {303 VaapiRateControl::Cbr => {304 ensure!(305 self.bitrate_kbps306 .is_some_and(|bitrate| (1_000..=100_000).contains(&bitrate)),307 "gamescope_pipewire.bitrate_kbps must be between 1000 and 100000 for CBR"308 );309 ensure!(310 self.quantizer.is_none(),311 "gamescope_pipewire.quantizer must be absent for CBR"312 );313 }314 VaapiRateControl::Cqp => {315 ensure!(316 self.quantizer317 .is_some_and(|quantizer| (1..=51).contains(&quantizer)),318 "gamescope_pipewire.quantizer must be between 1 and 51 for CQP"319 );320 ensure!(321 self.bitrate_kbps.is_none(),322 "gamescope_pipewire.bitrate_kbps must be absent for CQP"323 );324 }325 }326 Ok(())327 }328}329330impl PipewireAudioConfig {331 fn validate(&self) -> Result<()> {332 validate_pipewire_property("audio.node_name", &self.node_name)?;333 ensure!(334 self.media_class == "Audio/Sink",335 "audio.media_class must be Audio/Sink"336 );337 for (key, value) in &self.match_properties {338 validate_pipewire_property("audio.match_properties key", key)?;339 validate_pipewire_property("audio.match_properties value", value)?;340 ensure!(341 key != "node.name" && key != "media.class" && key != "object.serial",342 "audio.match_properties must not override {key}"343 );344 }345 ensure!(346 self.bitrate_bps == 96_000,347 "audio.bitrate_bps must be 96000 for the v1 Opus target"348 );349 Ok(())350 }351}352353fn validate_pipewire_property(name: &str, value: &str) -> Result<()> {354 ensure!(355 !value.is_empty() && value.len() <= 512 && !value.contains('\0'),356 "gamescope_pipewire.{name} must contain 1 to 512 non-NUL bytes"357 );358 Ok(())359}360361#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]362#[serde(deny_unknown_fields)]363pub struct HostConfig {364 pub identity_path: PathBuf,365 pub state_path: PathBuf,366 #[serde(default = "default_source")]367 pub source: VideoSource,368 /// Optional encoded-size override. Gamescope uses its advertised native369 /// size when both fields are absent; the test-pattern proof retains its370 /// 1280x800 fixture default.371 #[serde(default, skip_serializing_if = "Option::is_none")]372 pub width: Option<u32>,373 #[serde(default, skip_serializing_if = "Option::is_none")]374 pub height: Option<u32>,375 #[serde(default = "default_framerate")]376 pub framerate: u32,377 #[serde(default = "default_codec")]378 pub codec: String,379 #[serde(default = "default_input_mode")]380 pub input_mode: InputMode,381 /// Required only when input_mode is uinput.382 #[serde(default)]383 pub uinput: Option<UinputConfig>,384 #[serde(default = "default_ffmpeg")]385 pub ffmpeg_path: PathBuf,386 /// Required only for the production Gamescope PipeWire source.387 #[serde(default)]388 pub gamescope_pipewire: Option<GamescopePipewireConfig>,389 /// Optional exact PipeWire sink monitor. Absence keeps the host video-only.390 #[serde(default)]391 pub audio: Option<PipewireAudioConfig>,392}393394impl HostConfig {395 pub const TEST_PATTERN_WIDTH: u32 = 1_280;396 pub const TEST_PATTERN_HEIGHT: u32 = 800;397398 pub fn load(path: &Path) -> Result<Self> {399 Ok(Self::load_document(path)?.config)400 }401402 pub fn load_document(path: &Path) -> Result<LoadedHostConfig> {403 let parent = path404 .parent()405 .filter(|parent| !parent.as_os_str().is_empty())406 .unwrap_or(Path::new("."));407 crate::secure_state::validate_integrity_protected_directory(parent).with_context(|| {408 format!(409 "validating config directory {} before loading {}",410 parent.display(),411 path.display()412 )413 })?;414 let mut options = OpenOptions::new();415 options.read(true);416 #[cfg(unix)]417 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);418 let mut file = options.open(path).with_context(|| {419 format!(420 "opening config {} without following symlinks",421 path.display()422 )423 })?;424 let metadata = file425 .metadata()426 .with_context(|| format!("inspecting opened config {}", path.display()))?;427 validate_file_security(path, &metadata)?;428 ensure!(429 metadata.len() <= MAX_CONFIG_BYTES,430 "config exceeds its fixed size bound"431 );432 let mut bytes = Vec::with_capacity(metadata.len() as usize);433 Read::by_ref(&mut file)434 .take(MAX_CONFIG_BYTES.saturating_add(1))435 .read_to_end(&mut bytes)436 .with_context(|| format!("reading config {}", path.display()))?;437 ensure!(438 bytes.len() as u64 <= MAX_CONFIG_BYTES,439 "config exceeds its fixed size bound"440 );441 let config =442 Self::parse(&bytes).with_context(|| format!("parsing config {}", path.display()))?;443 let revision = ConfigRevision::from_bytes(&bytes);444 Ok(LoadedHostConfig {445 config,446 bytes,447 revision,448 })449 }450451 pub fn parse(bytes: &[u8]) -> Result<Self> {452 ensure!(453 bytes.len() as u64 <= MAX_CONFIG_BYTES,454 "config exceeds its fixed size bound"455 );456 let contents = std::str::from_utf8(bytes).context("config is not valid UTF-8")?;457 let config: Self = toml::from_str(contents).context("parsing config TOML")?;458 config.validate()?;459 Ok(config)460 }461462 pub fn validate(&self) -> Result<()> {463 ensure!(464 !self.identity_path.as_os_str().is_empty(),465 "identity_path is required"466 );467 ensure!(468 !self.state_path.as_os_str().is_empty(),469 "state_path is required"470 );471 match (self.width, self.height) {472 (Some(width), Some(height)) => validate_video_dimensions(width, height)?,473 (None, None) => {}474 _ => anyhow::bail!("width and height must either both be set or both be absent"),475 }476 ensure!(477 (1..=240).contains(&self.framerate),478 "framerate must be between 1 and 240"479 );480 ensure!(481 self.codec == "h264",482 "only h264 is supported during the first proof"483 );484 ensure!(485 !self.ffmpeg_path.as_os_str().is_empty(),486 "ffmpeg_path is required"487 );488 match (&self.input_mode, &self.uinput) {489 (InputMode::Uinput, Some(config)) => config.validate()?,490 (InputMode::Uinput, None) => {491 anyhow::bail!("uinput configuration is required when input_mode is uinput");492 }493 (InputMode::Log | InputMode::Disabled, None) => {}494 (InputMode::Log | InputMode::Disabled, Some(_)) => {495 anyhow::bail!("uinput configuration must be absent unless input_mode is uinput");496 }497 }498 match (&self.source, &self.gamescope_pipewire) {499 (VideoSource::TestPattern, None) => {}500 (VideoSource::TestPattern, Some(_)) => {501 anyhow::bail!("gamescope_pipewire must be absent when source is test-pattern");502 }503 (VideoSource::GamescopePipewire, Some(config)) => config.validate()?,504 (VideoSource::GamescopePipewire, None) => {505 anyhow::bail!(506 "gamescope_pipewire configuration is required when source is gamescope-pipewire"507 );508 }509 }510 match (&self.source, &self.gamescope_pipewire, &self.audio) {511 (_, _, None) => {}512 (VideoSource::GamescopePipewire, Some(_), Some(audio)) => audio.validate()?,513 _ => anyhow::bail!(514 "audio requires source=gamescope-pipewire and gamescope_pipewire configuration"515 ),516 }517 Ok(())518 }519520 pub fn configured_dimensions(&self) -> Option<(u32, u32)> {521 self.width.zip(self.height)522 }523524 pub fn test_pattern_dimensions(&self) -> Result<(u32, u32)> {525 ensure!(526 self.source == VideoSource::TestPattern,527 "test-pattern dimensions requested for a non-test source"528 );529 Ok(self530 .configured_dimensions()531 .unwrap_or((Self::TEST_PATTERN_WIDTH, Self::TEST_PATTERN_HEIGHT)))532 }533534 pub fn ensure_runtime_directory(&self) -> Result<()> {535 if self.state_path.exists() {536 let metadata = fs::symlink_metadata(&self.state_path)537 .with_context(|| format!("inspecting state_path {}", self.state_path.display()))?;538 ensure!(539 !metadata.file_type().is_symlink(),540 "state_path must not be a symlink"541 );542 ensure!(metadata.is_dir(), "state_path must be a directory");543 #[cfg(unix)]544 {545 ensure!(546 metadata.mode() & 0o077 == 0,547 "state_path {} must not be accessible by group or other users",548 self.state_path.display()549 );550 ensure!(551 metadata.uid() == unsafe { libc::geteuid() },552 "state_path {} is not owned by the current user",553 self.state_path.display()554 );555 }556 } else {557 #[cfg(unix)]558 {559 use std::os::unix::fs::DirBuilderExt;560 let mut builder = fs::DirBuilder::new();561 builder562 .recursive(true)563 .mode(0o700)564 .create(&self.state_path)?;565 }566 #[cfg(not(unix))]567 fs::create_dir_all(&self.state_path)?;568 }569 Ok(())570 }571}572573fn validate_video_dimensions(width: u32, height: u32) -> Result<()> {574 ensure!(575 (64..=7680).contains(&width),576 "width must be between 64 and 7680"577 );578 ensure!(579 (64..=4320).contains(&height),580 "height must be between 64 and 4320"581 );582 ensure!(583 width.is_multiple_of(2) && height.is_multiple_of(2),584 "H.264 dimensions must be even"585 );586 Ok(())587}588589fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {590 ensure!(metadata.is_file(), "config must be a regular file");591 #[cfg(unix)]592 {593 ensure!(594 metadata.permissions().mode() & 0o022 == 0,595 "config {} must not be writable by group or other users",596 path.display()597 );598 ensure!(599 metadata.uid() == unsafe { libc::geteuid() },600 "config {} is not owned by the current user",601 path.display()602 );603 }604 Ok(())605}606607#[cfg(test)]608mod tests {609 use super::*;610611 #[test]612 fn config_revision_is_exact_bounded_and_strict() {613 let first = ConfigRevision::from_bytes(b"a = 1\n");614 let second = ConfigRevision::from_bytes(b"a = 1\n\n");615 assert_ne!(first, second);616 assert_eq!(ConfigRevision::parse(first.as_str()).unwrap(), first);617 assert!(ConfigRevision::parse("sha256:ABC").is_err());618 assert!(ConfigRevision::parse("md5:00000000000000000000000000000000").is_err());619 }620621 #[test]622 fn load_document_hashes_the_same_bounded_bytes_it_parses() {623 let directory = tempfile::tempdir().unwrap();624 let path = directory.path().join("host.toml");625 let bytes = b"identity_path = \"/tmp/host.key\"\nstate_path = \"/tmp/state\"\n";626 fs::write(&path, bytes).unwrap();627 let loaded = HostConfig::load_document(&path).unwrap();628 assert_eq!(loaded.bytes, bytes);629 assert_eq!(loaded.revision, ConfigRevision::from_bytes(bytes));630631 fs::write(&path, vec![b' '; MAX_CONFIG_BYTES as usize + 1]).unwrap();632 assert!(HostConfig::load_document(&path).is_err());633 }634635 #[cfg(unix)]636 #[test]637 fn config_directory_protects_integrity_without_requiring_confidentiality() {638 use std::os::unix::fs::symlink;639640 let outer = tempfile::tempdir().unwrap();641 let directory = outer.path().join("config");642 fs::create_dir(&directory).unwrap();643 fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();644 let path = directory.join("host.toml");645 fs::write(646 &path,647 b"identity_path = \"/tmp/host.key\"\nstate_path = \"/tmp/state\"\n",648 )649 .unwrap();650 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();651 HostConfig::load_document(&path).unwrap();652653 fs::set_permissions(&directory, fs::Permissions::from_mode(0o775)).unwrap();654 assert!(HostConfig::load_document(&path).is_err());655 fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();656657 let linked_directory = outer.path().join("linked-config");658 symlink(&directory, &linked_directory).unwrap();659 assert!(HostConfig::load_document(&linked_directory.join("host.toml")).is_err());660 }661662 #[test]663 fn rejects_unknown_fields() {664 let input = r#"665identity_path = "/tmp/host.key"666state_path = "/tmp/state"667surprise = true668"#;669 assert!(toml::from_str::<HostConfig>(input).is_err());670 }671672 #[test]673 fn validates_first_target() {674 let input = r#"675identity_path = "/tmp/host.key"676state_path = "/tmp/state"677source = "test-pattern"678width = 1280679height = 800680framerate = 60681codec = "h264"682"#;683 let config: HostConfig = toml::from_str(input).unwrap();684 config.validate().unwrap();685 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));686 }687688 #[test]689 fn test_pattern_omission_retains_the_proof_fixture() {690 let config: HostConfig = toml::from_str(691 r#"692identity_path = "/tmp/host.key"693state_path = "/tmp/state"694"#,695 )696 .unwrap();697 config.validate().unwrap();698 assert_eq!(config.configured_dimensions(), None);699 assert_eq!(config.test_pattern_dimensions().unwrap(), (1_280, 800));700 }701702 #[test]703 fn validates_strict_gamescope_pipewire_configuration() {704 let input = r#"705identity_path = "/tmp/host.key"706state_path = "/tmp/state"707source = "gamescope-pipewire"708framerate = 60709codec = "h264"710711[gamescope_pipewire]712node_name = "gamescope"713media_class = "Video/Source"714xwayland_display = ":0"715pw_dump_path = "/usr/bin/pw-dump"716gst_launch_path = "/usr/bin/gst-launch-1.0"717gst_inspect_path = "/usr/bin/gst-inspect-1.0"718encoder_backend = "external-gst-launch"719vaapi_encoder = "vah264enc"720vaapi_render_node = "/dev/dri/renderD128"721rate_control = "cbr"722bitrate_kbps = 12000723724[gamescope_pipewire.match_properties]725"device.bus-path" = "pci-0000:04:00.0"726727[audio]728node_name = "sigil-game-audio"729media_class = "Audio/Sink"730bitrate_bps = 96000731732[audio.match_properties]733"device.profile.name" = "stereo"734"#;735 let config: HostConfig = toml::from_str(input).unwrap();736 config.validate().unwrap();737 assert_eq!(config.configured_dimensions(), None);738 assert_eq!(739 config740 .gamescope_pipewire741 .as_ref()742 .unwrap()743 .xwayland_display744 .as_deref(),745 Some(":0")746 );747 assert_eq!(748 config.gamescope_pipewire.as_ref().unwrap().encoder_backend,749 GamescopeEncoderBackend::ExternalGstLaunch750 );751 }752753 #[test]754 fn audio_is_optional_and_strictly_tied_to_gamescope() {755 let mut config: HostConfig = toml::from_str(756 r#"757identity_path = "/tmp/host.key"758state_path = "/tmp/state"759source = "gamescope-pipewire"760761[gamescope_pipewire]762node_name = "gamescope"763media_class = "Video/Source"764pw_dump_path = "/usr/bin/pw-dump"765gst_launch_path = "/usr/bin/gst-launch-1.0"766gst_inspect_path = "/usr/bin/gst-inspect-1.0"767vaapi_encoder = "vah264enc"768vaapi_render_node = "/dev/dri/renderD128"769rate_control = "cbr"770bitrate_kbps = 12000771772[audio]773node_name = "sigil-game-audio"774media_class = "Audio/Sink"775bitrate_bps = 96000776"#,777 )778 .unwrap();779 config.validate().unwrap();780781 config.source = VideoSource::TestPattern;782 assert!(config.validate().is_err());783 config.source = VideoSource::GamescopePipewire;784785 config.audio.as_mut().unwrap().media_class = "Audio/Source".into();786 assert!(config.validate().is_err());787 config.audio.as_mut().unwrap().media_class = "Audio/Sink".into();788 config.audio.as_mut().unwrap().bitrate_bps = 128_000;789 assert!(config.validate().is_err());790 config.audio.as_mut().unwrap().bitrate_bps = 96_000;791 config792 .audio793 .as_mut()794 .unwrap()795 .match_properties796 .insert("object.serial".into(), "50".into());797 assert!(config.validate().is_err());798 }799800 #[test]801 fn gamescope_configuration_fails_closed() {802 let mut config: HostConfig = toml::from_str(803 r#"804identity_path = "/tmp/host.key"805state_path = "/tmp/state"806source = "gamescope-pipewire"807"#,808 )809 .unwrap();810 assert!(config.validate().is_err());811812 config.source = VideoSource::TestPattern;813 config.gamescope_pipewire = Some(GamescopePipewireConfig {814 xwayland_display: None,815 node_name: "gamescope".into(),816 media_class: "Video/Source".into(),817 match_properties: BTreeMap::new(),818 pw_dump_path: "/usr/bin/pw-dump".into(),819 gst_launch_path: "/usr/bin/gst-launch-1.0".into(),820 gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),821 encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,822 vaapi_encoder: "vah264enc".into(),823 vaapi_render_node: "/dev/dri/renderD128".into(),824 rate_control: VaapiRateControl::Cbr,825 bitrate_kbps: Some(12_000),826 quantizer: None,827 });828 assert!(config.validate().is_err());829830 config.source = VideoSource::GamescopePipewire;831 config.gamescope_pipewire.as_mut().unwrap().xwayland_display = Some("localhost:0".into());832 assert!(config.validate().is_err());833 config.gamescope_pipewire.as_mut().unwrap().xwayland_display = Some(":0".into());834 config.validate().unwrap();835 config.gamescope_pipewire.as_mut().unwrap().vaapi_encoder = "x264enc".into();836 assert!(config.validate().is_err());837 }838839 #[test]840 fn gamescope_encoder_backend_defaults_external() {841 let config: HostConfig = toml::from_str(842 r#"843identity_path = "/tmp/host.key"844state_path = "/tmp/state"845source = "gamescope-pipewire"846847[gamescope_pipewire]848node_name = "gamescope"849media_class = "Video/Source"850pw_dump_path = "/usr/bin/pw-dump"851gst_launch_path = "/usr/bin/gst-launch-1.0"852gst_inspect_path = "/usr/bin/gst-inspect-1.0"853vaapi_encoder = "vah264enc"854vaapi_render_node = "/dev/dri/renderD128"855rate_control = "cbr"856bitrate_kbps = 12000857"#,858 )859 .unwrap();860861 assert_eq!(862 config.gamescope_pipewire.unwrap().encoder_backend,863 GamescopeEncoderBackend::ExternalGstLaunch864 );865 }866867 #[cfg(not(all(target_os = "linux", feature = "in-process-gstreamer")))]868 #[test]869 fn unavailable_in_process_backend_fails_closed() {870 let mut config: HostConfig = toml::from_str(871 r#"872identity_path = "/tmp/host.key"873state_path = "/tmp/state"874source = "gamescope-pipewire"875876[gamescope_pipewire]877node_name = "gamescope"878media_class = "Video/Source"879pw_dump_path = "/usr/bin/pw-dump"880gst_launch_path = "/usr/bin/gst-launch-1.0"881gst_inspect_path = "/usr/bin/gst-inspect-1.0"882vaapi_encoder = "vah264enc"883vaapi_render_node = "/dev/dri/renderD128"884rate_control = "cbr"885bitrate_kbps = 12000886"#,887 )888 .unwrap();889 config.gamescope_pipewire.as_mut().unwrap().encoder_backend =890 GamescopeEncoderBackend::InProcessGstreamer;891892 assert!(config.validate().is_err());893 }894895 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]896 #[test]897 fn feature_built_linux_accepts_in_process_backend() {898 let mut config: HostConfig = toml::from_str(899 r#"900identity_path = "/tmp/host.key"901state_path = "/tmp/state"902source = "gamescope-pipewire"903904[gamescope_pipewire]905node_name = "gamescope"906media_class = "Video/Source"907pw_dump_path = "/usr/bin/pw-dump"908gst_launch_path = "/usr/bin/gst-launch-1.0"909gst_inspect_path = "/usr/bin/gst-inspect-1.0"910vaapi_encoder = "vah264enc"911vaapi_render_node = "/dev/dri/renderD128"912rate_control = "cbr"913bitrate_kbps = 12000914"#,915 )916 .unwrap();917 let pipewire = config.gamescope_pipewire.as_mut().unwrap();918 pipewire.encoder_backend = GamescopeEncoderBackend::InProcessGstreamer;919920 config.validate().unwrap();921 let pipewire = config.gamescope_pipewire.unwrap();922 assert_eq!(923 pipewire.encoder_backend,924 GamescopeEncoderBackend::InProcessGstreamer925 );926 assert_eq!(pipewire.rate_control, VaapiRateControl::Cbr);927 }928929 #[test]930 fn in_process_cqp_fails_closed() {931 let pipewire = GamescopePipewireConfig {932 node_name: "gamescope".into(),933 media_class: "Video/Source".into(),934 match_properties: BTreeMap::new(),935 xwayland_display: None,936 pw_dump_path: "/usr/bin/pw-dump".into(),937 gst_launch_path: "/usr/bin/gst-launch-1.0".into(),938 gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),939 encoder_backend: GamescopeEncoderBackend::InProcessGstreamer,940 vaapi_encoder: "vah264enc".into(),941 vaapi_render_node: "/dev/dri/renderD128".into(),942 rate_control: VaapiRateControl::Cqp,943 bitrate_kbps: None,944 quantizer: Some(24),945 };946947 assert!(pipewire.validate().is_err());948 }949950 #[test]951 fn rejects_odd_dimensions_and_codec_breadth() {952 let mut config: HostConfig = toml::from_str(953 r#"954identity_path = "/tmp/host.key"955state_path = "/tmp/state"956"#,957 )958 .unwrap();959 config.width = Some(1279);960 assert!(config.validate().is_err());961 config.width = Some(1280);962 config.codec = "av1".into();963 assert!(config.validate().is_err());964 }965966 #[test]967 fn rejects_partial_dimension_override() {968 let config: HostConfig = toml::from_str(969 r#"970identity_path = "/tmp/host.key"971state_path = "/tmp/state"972width = 1280973"#,974 )975 .unwrap();976 assert!(977 config978 .validate()979 .unwrap_err()980 .to_string()981 .contains("width and height must either both be set or both be absent")982 );983 }984985 #[test]986 fn validates_strict_uinput_configuration() {987 let config: HostConfig = toml::from_str(988 r#"989identity_path = "/tmp/host.key"990state_path = "/tmp/state"991input_mode = "uinput"992993[uinput]994device_path = "/dev/uinput"995expected_owner_uid = 0996expected_group_gid = 986997expected_mode = 0o660998"#,999 )1000 .unwrap();1001 config.validate().unwrap();1002 assert_eq!(config.uinput.as_ref().unwrap().expected_acl_user_uid, None);10031004 let acl_config: HostConfig = toml::from_str(1005 r#"1006identity_path = "/tmp/host.key"1007state_path = "/tmp/state"1008input_mode = "uinput"10091010[uinput]1011device_path = "/dev/uinput"1012expected_owner_uid = 01013expected_group_gid = 9861014expected_mode = 0o6601015expected_acl_user_uid = 10001016"#,1017 )1018 .unwrap();1019 acl_config.validate().unwrap();1020 assert_eq!(1021 acl_config.uinput.as_ref().unwrap().expected_acl_user_uid,1022 Some(1000)1023 );1024 }10251026 #[test]1027 fn uinput_configuration_fails_closed() {1028 let mut config: HostConfig = toml::from_str(1029 r#"1030identity_path = "/tmp/host.key"1031state_path = "/tmp/state"1032input_mode = "uinput"10331034[uinput]1035device_path = "/tmp/uinput"1036expected_owner_uid = 01037expected_group_gid = 9861038expected_mode = 0o6661039"#,1040 )1041 .unwrap();1042 assert!(config.validate().is_err());10431044 config.uinput.as_mut().unwrap().device_path = "/dev/uinput".into();1045 assert!(config.validate().is_err());10461047 config.uinput.as_mut().unwrap().expected_mode = 0o660;1048 config.input_mode = InputMode::Disabled;1049 assert!(config.validate().is_err());1050 }10511052 #[test]1053 fn explicit_uinput_acl_principal_fails_closed() {1054 let mut config: HostConfig = toml::from_str(1055 r#"1056identity_path = "/tmp/host.key"1057state_path = "/tmp/state"1058input_mode = "uinput"10591060[uinput]1061device_path = "/dev/uinput"1062expected_owner_uid = 01063expected_group_gid = 9861064expected_mode = 0o6601065expected_acl_user_uid = 10001066"#,1067 )1068 .unwrap();10691070 config.uinput.as_mut().unwrap().expected_acl_user_uid = Some(0);1071 assert!(1072 config1073 .validate()1074 .unwrap_err()1075 .to_string()1076 .contains("must differ from expected_owner_uid")1077 );10781079 let uinput = config.uinput.as_mut().unwrap();1080 uinput.expected_acl_user_uid = Some(1000);1081 uinput.expected_mode = 0o640;1082 assert!(1083 config1084 .validate()1085 .unwrap_err()1086 .to_string()1087 .contains("ACL mask read/write")1088 );1089 }10901091 #[cfg(unix)]1092 #[test]1093 fn rejects_symlink_and_permissive_runtime_directories() {1094 use std::os::unix::fs::symlink;10951096 let temp = tempfile::tempdir().unwrap();1097 let target = temp.path().join("target");1098 fs::create_dir(&target).unwrap();1099 fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap();1100 let link = temp.path().join("link");1101 symlink(&target, &link).unwrap();11021103 let mut config: HostConfig = toml::from_str(1104 r#"1105identity_path = "/tmp/host.key"1106state_path = "/tmp/state"1107"#,1108 )1109 .unwrap();1110 config.state_path = link;1111 assert!(config.ensure_runtime_directory().is_err());11121113 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();1114 config.state_path = target;1115 assert!(config.ensure_runtime_directory().is_err());1116 }1117}