1use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use serde::{Deserialize, Serialize};
9
10use crate::{Error, Result};
11
12pub(super) const DEFAULTS_TEXT: &str = include_str!("defaults.toml");
13
14static DEFAULTS: LazyLock<ComputerDefaults> = LazyLock::new(|| {
17 let defaults: ComputerDefaults = mobius::config::embedded(DEFAULTS_TEXT);
18 for application in [
19 &defaults.applications.window_manager,
20 &defaults.applications.panel,
21 &defaults.applications.wallpaper,
22 &defaults.applications.terminal,
23 &defaults.applications.files,
24 ] {
25 command_name(&application.executable).expect("valid embedded desktop command");
26 for argument in &application.arguments {
27 valid_argument(argument).expect("valid embedded desktop argument");
28 }
29 }
30 defaults
31});
32
33#[derive(Deserialize)]
34#[serde(deny_unknown_fields)]
35struct ComputerDefaults {
36 mode: RuntimeMode,
37 install_timeout_seconds: u64,
38 download_connect_timeout_seconds: u64,
39 download_timeout_seconds: u64,
40 node_download_base_url: String,
41 tar_executable: String,
42 node: NodeDefaults,
43 browser: BrowserDefaults,
44 desktop: DesktopDefaults,
45 applications: ApplicationDefaults,
46 labels: LabelDefaults,
47}
48
49#[derive(Deserialize)]
50#[serde(deny_unknown_fields)]
51struct BrowserDefaults {
52 sandbox: bool,
53 arguments: Vec<String>,
54 window_size: [u32; 2],
55 window_position: [i32; 2],
56 start_page: String,
57 viewport: [u32; 2],
58}
59
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct DesktopDefaults {
63 resolution: [u32; 2],
64 depth: u8,
65 display_start: u16,
66 display_end: u16,
67 startup_timeout_seconds: u64,
68 tools: ToolDefaults,
69}
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields)]
73struct ToolDefaults {
74 xauth: String,
75 xvnc: Vec<String>,
76 vncconfig: Vec<String>,
77 setpriv: String,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82struct ApplicationDefaults {
83 window_manager: ApplicationCommand,
84 panel: ApplicationCommand,
85 wallpaper: ApplicationCommand,
86 terminal: ApplicationCommand,
87 files: ApplicationCommand,
88}
89
90#[derive(Deserialize)]
91#[serde(deny_unknown_fields)]
92struct ApplicationCommand {
93 executable: String,
94 arguments: Vec<String>,
95}
96
97#[derive(Deserialize)]
98#[serde(deny_unknown_fields)]
99struct NodeDefaults {
100 version: String,
101 distributions: BTreeMap<String, NodeDistribution>,
102}
103
104#[derive(Deserialize)]
105#[serde(deny_unknown_fields)]
106struct NodeDistribution {
107 platform: String,
108 checksum: String,
109}
110
111#[derive(Deserialize)]
112#[serde(deny_unknown_fields)]
113struct LabelDefaults {
114 browser: String,
115 terminal: String,
116 files: String,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum RuntimeMode {
123 Managed,
125 Preinstalled,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(default, deny_unknown_fields)]
132pub struct ComputerConfig {
133 pub mode: RuntimeMode,
135 pub directory: Option<PathBuf>,
137 pub node_executable: Option<PathBuf>,
139 pub npm_executable: Option<PathBuf>,
141 pub playwright_module: Option<PathBuf>,
143 pub browsers_directory: Option<PathBuf>,
145 pub install_timeout_seconds: u64,
147 pub download_connect_timeout_seconds: u64,
149 pub download_timeout_seconds: u64,
151 pub node_download_base_url: Cow<'static, str>,
153 pub tar_executable: Cow<'static, str>,
155 pub ca_file: Option<PathBuf>,
158 pub browser: BrowserConfig,
160 pub desktop: DesktopConfig,
162}
163
164impl Default for ComputerConfig {
165 fn default() -> Self {
166 Self {
167 mode: DEFAULTS.mode,
168 directory: None,
169 node_executable: None,
170 npm_executable: None,
171 playwright_module: None,
172 browsers_directory: None,
173 install_timeout_seconds: DEFAULTS.install_timeout_seconds,
174 download_connect_timeout_seconds: DEFAULTS.download_connect_timeout_seconds,
175 download_timeout_seconds: DEFAULTS.download_timeout_seconds,
176 node_download_base_url: Cow::Borrowed(&DEFAULTS.node_download_base_url),
177 tar_executable: Cow::Borrowed(&DEFAULTS.tar_executable),
178 ca_file: None,
179 browser: BrowserConfig::default(),
180 desktop: DesktopConfig::default(),
181 }
182 }
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(default, deny_unknown_fields)]
188pub struct BrowserConfig {
189 pub executable: Option<PathBuf>,
191 pub profile_directory: Option<PathBuf>,
193 pub sandbox: bool,
195 pub arguments: Cow<'static, [String]>,
197 pub window_size: [u32; 2],
199 pub window_position: [i32; 2],
201 pub start_page: Cow<'static, str>,
203 pub viewport: [u32; 2],
205}
206
207impl Default for BrowserConfig {
208 fn default() -> Self {
209 Self {
210 executable: None,
211 profile_directory: None,
212 sandbox: DEFAULTS.browser.sandbox,
213 arguments: Cow::Borrowed(&DEFAULTS.browser.arguments),
214 window_size: DEFAULTS.browser.window_size,
215 window_position: DEFAULTS.browser.window_position,
216 start_page: Cow::Borrowed(&DEFAULTS.browser.start_page),
217 viewport: DEFAULTS.browser.viewport,
218 }
219 }
220}
221
222#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(default, deny_unknown_fields)]
225pub struct DesktopApplication {
226 pub enabled: bool,
228 pub executable: Option<String>,
230 pub arguments: Option<Vec<String>>,
232}
233
234impl Default for DesktopApplication {
235 fn default() -> Self {
236 Self {
237 enabled: true,
238 executable: None,
239 arguments: None,
240 }
241 }
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct DesktopTools {
248 pub xauth: Cow<'static, str>,
250 pub xvnc: Cow<'static, [String]>,
252 pub vncconfig: Cow<'static, [String]>,
254 pub setpriv: Cow<'static, str>,
256}
257
258impl Default for DesktopTools {
259 fn default() -> Self {
260 let tools = &DEFAULTS.desktop.tools;
261 Self {
262 xauth: Cow::Borrowed(&tools.xauth),
263 xvnc: Cow::Borrowed(&tools.xvnc),
264 vncconfig: Cow::Borrowed(&tools.vncconfig),
265 setpriv: Cow::Borrowed(&tools.setpriv),
266 }
267 }
268}
269
270#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(default, deny_unknown_fields)]
273pub struct DesktopBranding {
274 pub wallpaper: Option<PathBuf>,
276 pub logo: Option<PathBuf>,
278 pub browser_icon: Option<PathBuf>,
280 pub terminal_icon: Option<PathBuf>,
282 pub files_icon: Option<PathBuf>,
284 pub panel_config: Option<PathBuf>,
286 pub status_config: Option<PathBuf>,
288 pub browser_label: Cow<'static, str>,
290 pub terminal_label: Cow<'static, str>,
292 pub files_label: Cow<'static, str>,
294}
295
296impl Default for DesktopBranding {
297 fn default() -> Self {
298 Self {
299 wallpaper: None,
300 logo: None,
301 browser_icon: None,
302 terminal_icon: None,
303 files_icon: None,
304 panel_config: None,
305 status_config: None,
306 browser_label: Cow::Borrowed(&DEFAULTS.labels.browser),
307 terminal_label: Cow::Borrowed(&DEFAULTS.labels.terminal),
308 files_label: Cow::Borrowed(&DEFAULTS.labels.files),
309 }
310 }
311}
312
313#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(default, deny_unknown_fields)]
316pub struct DesktopConfig {
317 pub resolution: [u32; 2],
319 pub depth: u8,
321 pub display_start: u16,
323 pub display_end: u16,
325 pub startup_timeout_seconds: u64,
327 pub tools: DesktopTools,
329 pub window_manager: DesktopApplication,
331 pub panel: DesktopApplication,
333 pub wallpaper: DesktopApplication,
335 pub terminal: DesktopApplication,
337 pub files: DesktopApplication,
339 pub branding: DesktopBranding,
341}
342
343impl Default for DesktopConfig {
344 fn default() -> Self {
345 Self {
346 resolution: DEFAULTS.desktop.resolution,
347 depth: DEFAULTS.desktop.depth,
348 display_start: DEFAULTS.desktop.display_start,
349 display_end: DEFAULTS.desktop.display_end,
350 startup_timeout_seconds: DEFAULTS.desktop.startup_timeout_seconds,
351 tools: DesktopTools::default(),
352 window_manager: DesktopApplication::default(),
353 panel: DesktopApplication::default(),
354 wallpaper: DesktopApplication::default(),
355 terminal: DesktopApplication::default(),
356 files: DesktopApplication::default(),
357 branding: DesktopBranding::default(),
358 }
359 }
360}
361
362impl ComputerConfig {
363 pub fn validate(&self) -> Result<()> {
368 for path in [
369 &self.directory,
370 &self.playwright_module,
371 &self.browsers_directory,
372 &self.ca_file,
373 ]
374 .into_iter()
375 .flatten()
376 {
377 absolute_path(path)?;
378 }
379 for path in [&self.node_executable, &self.npm_executable]
380 .into_iter()
381 .flatten()
382 {
383 command_name(path.as_os_str().to_str().ok_or_else(invalid_command)?)?;
384 }
385 deadline(self.install_timeout_seconds)?;
386 deadline(self.download_connect_timeout_seconds)?;
387 deadline(self.download_timeout_seconds)?;
388 let url = url::Url::parse(&self.node_download_base_url)
389 .map_err(|_| Error::Config("invalid Node download mirror".into()))?;
390 if url.scheme() != "https"
391 || url.host_str().is_none()
392 || !url.username().is_empty()
393 || url.password().is_some()
394 || url.query().is_some()
395 || url.fragment().is_some()
396 {
397 return Err(Error::Config(
398 "Node download mirror must be HTTPS without credentials".into(),
399 ));
400 }
401 command_name(&self.tar_executable)?;
402 self.browser.validate()?;
403 self.desktop.validate()
404 }
405}
406
407impl BrowserConfig {
408 pub(crate) fn validate_root_sandbox(&self, root: bool) -> Result<()> {
409 if root && self.sandbox {
410 return Err(Error::Config(super::ROOT_SANDBOX_ERROR.into()));
411 }
412 Ok(())
413 }
414
415 pub(crate) fn validate(&self) -> Result<()> {
416 if let Some(path) = &self.executable {
417 command_name(path.as_os_str().to_str().ok_or_else(invalid_command)?)?;
418 }
419 if let Some(path) = &self.profile_directory {
420 absolute_path(path)?;
421 }
422 dimensions(self.window_size)?;
423 dimensions(self.viewport)?;
424 if self.arguments.len() > 128 {
425 return Err(Error::Config("too many browser arguments".into()));
426 }
427 valid_text(&self.start_page)?;
428 for argument in self.arguments.iter() {
429 valid_text(argument)?;
430 let switch = argument.split('=').next().unwrap_or(argument);
431 if !argument.starts_with("--")
432 || argument.to_ascii_lowercase().contains("sandbox")
433 || switch.starts_with("--remote-debugging")
434 || [
435 "--remote-debugging-address",
436 "--remote-debugging-port",
437 "--remote-debugging-pipe",
438 "--remote-allow-origins",
439 "--user-data-dir",
440 "--profile-directory",
441 "--no-sandbox",
442 "--disable-setuid-sandbox",
443 "--disable-seccomp-filter-sandbox",
444 "--headless",
445 "--window-size",
446 "--window-position",
447 ]
448 .contains(&switch)
449 {
450 return Err(Error::Config(
451 "browser arguments cannot override private control or sandbox policy".into(),
452 ));
453 }
454 }
455 let url = url::Url::parse(&self.start_page)
456 .map_err(|_| Error::Config("invalid browser start page".into()))?;
457 if !matches!(url.scheme(), "http" | "https" | "about")
458 || (url.scheme() == "about" && self.start_page != "about:blank")
459 || !url.username().is_empty()
460 || url.password().is_some()
461 {
462 return Err(Error::Config(
463 "browser start page must be HTTP(S) or about:blank".into(),
464 ));
465 }
466 Ok(())
467 }
468
469 pub(crate) fn launch_arguments(&self) -> impl Iterator<Item = &str> {
470 self.arguments
471 .iter()
472 .map(String::as_str)
473 .chain((!self.sandbox).then_some("--no-sandbox"))
474 }
475
476 pub(crate) fn headed_arguments(&self, profile: &Path) -> Vec<Cow<'_, str>> {
477 let mut arguments = self
478 .launch_arguments()
479 .map(Cow::Borrowed)
480 .collect::<Vec<_>>();
481 arguments.extend([
482 Cow::Owned(format!(
483 "--window-size={},{}",
484 self.window_size[0], self.window_size[1]
485 )),
486 Cow::Owned(format!(
487 "--window-position={},{}",
488 self.window_position[0], self.window_position[1]
489 )),
490 "--remote-debugging-address=127.0.0.1".into(),
491 "--remote-debugging-port=0".into(),
492 Cow::Owned(format!("--user-data-dir={}", profile.display())),
493 Cow::Borrowed(&self.start_page),
494 ]);
495 arguments
496 }
497}
498
499impl DesktopConfig {
500 fn validate(&self) -> Result<()> {
501 dimensions(self.resolution)?;
502 deadline(self.startup_timeout_seconds)?;
503 if !matches!(self.depth, 16 | 24 | 32)
504 || self.display_start == 0
505 || self.display_end <= self.display_start
506 || self.display_end > 60000
507 || self.display_end - self.display_start > 256
508 {
509 return Err(Error::Config(
510 "invalid desktop depth or display range".into(),
511 ));
512 }
513 for command in [self.tools.xauth.as_ref(), self.tools.setpriv.as_ref()]
514 .into_iter()
515 .chain(self.tools.xvnc.iter().map(String::as_str))
516 .chain(self.tools.vncconfig.iter().map(String::as_str))
517 {
518 command_name(command)?;
519 }
520 if self.tools.xvnc.is_empty() || self.tools.vncconfig.is_empty() {
521 return Err(invalid_command());
522 }
523 for application in [
524 &self.window_manager,
525 &self.panel,
526 &self.wallpaper,
527 &self.terminal,
528 &self.files,
529 ] {
530 if let Some(executable) = &application.executable {
531 command_name(executable)?;
532 }
533 if let Some(arguments) = &application.arguments {
534 if arguments.len() > 128 {
535 return Err(Error::Config("too many desktop arguments".into()));
536 }
537 for argument in arguments {
538 valid_argument(argument)?;
539 }
540 }
541 }
542 for path in [
543 &self.branding.wallpaper,
544 &self.branding.logo,
545 &self.branding.browser_icon,
546 &self.branding.terminal_icon,
547 &self.branding.files_icon,
548 &self.branding.panel_config,
549 &self.branding.status_config,
550 ]
551 .into_iter()
552 .flatten()
553 {
554 absolute_path(path)?;
555 }
556 for label in [
557 &self.branding.browser_label,
558 &self.branding.terminal_label,
559 &self.branding.files_label,
560 ] {
561 valid_text(label)?;
562 }
563 Ok(())
564 }
565}
566
567#[cfg(any(target_os = "linux", test))]
569pub(crate) enum ApplicationRole {
570 WindowManager,
571 Panel,
572 Wallpaper,
573 Terminal,
574 Files,
575}
576
577#[cfg(any(target_os = "linux", test))]
578pub(crate) fn application(
579 config: &DesktopApplication,
580 role: ApplicationRole,
581) -> Result<Option<(PathBuf, &[String])>> {
582 if !config.enabled {
583 return Ok(None);
584 }
585 let defaults = match role {
586 ApplicationRole::WindowManager => &DEFAULTS.applications.window_manager,
587 ApplicationRole::Panel => &DEFAULTS.applications.panel,
588 ApplicationRole::Wallpaper => &DEFAULTS.applications.wallpaper,
589 ApplicationRole::Terminal => &DEFAULTS.applications.terminal,
590 ApplicationRole::Files => &DEFAULTS.applications.files,
591 };
592 let executable = config.executable.as_deref().unwrap_or(&defaults.executable);
593 let path = match resolve_executable(Path::new(executable)) {
594 Ok(path) => path,
595 Err(_) if config.executable.is_none() => return Ok(None),
596 Err(error) => return Err(error),
597 };
598 let arguments = config.arguments.as_deref().unwrap_or(&defaults.arguments);
599 Ok(Some((path, arguments)))
600}
601
602pub(crate) fn resolve_executable(command: &Path) -> Result<PathBuf> {
603 command_name(command.as_os_str().to_str().ok_or_else(invalid_command)?)?;
604 let path = if command.is_absolute() {
605 Some(Cow::Borrowed(command))
606 } else {
607 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
608 .map(|directory| directory.join(command))
609 .find(|path| is_executable(path))
610 .map(Cow::Owned)
611 };
612 let path = path.filter(|path| is_executable(path)).ok_or_else(|| {
613 Error::Config(format!(
614 "computer runtime executable is unavailable: {}",
615 command.display()
616 ))
617 })?;
618 Ok(std::fs::canonicalize(path)?)
619}
620
621pub(crate) fn resolve_candidates(commands: &[String]) -> Result<PathBuf> {
622 for command in commands {
623 if let Ok(path) = resolve_executable(Path::new(command)) {
624 return Ok(path);
625 }
626 }
627 Err(Error::Config(format!(
628 "desktop requires {}",
629 commands.join(" or ")
630 )))
631}
632
633pub(crate) fn node_version() -> &'static str {
634 &DEFAULTS.node.version
635}
636
637pub(crate) fn node_distribution(os: &str, arch: &str) -> Result<(&'static str, &'static str)> {
638 let key = format!("{os}-{arch}");
639 let distribution =
640 DEFAULTS.node.distributions.get(&key).ok_or_else(|| {
641 Error::Config(format!("computer control has no runtime for {os}/{arch}"))
642 })?;
643 Ok((&distribution.platform, &distribution.checksum))
644}
645
646fn is_executable(path: &Path) -> bool {
647 #[cfg(unix)]
648 {
649 use std::os::unix::fs::PermissionsExt as _;
650 path.metadata()
651 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
652 }
653 #[cfg(not(unix))]
654 {
655 path.is_file()
656 }
657}
658
659fn absolute_path(path: &Path) -> Result<()> {
660 if !path.is_absolute()
661 || path
662 .components()
663 .any(|part| matches!(part, std::path::Component::ParentDir))
664 {
665 return Err(Error::Config(
666 "computer resource paths must be absolute without parent traversal".into(),
667 ));
668 }
669 valid_text(path.to_str().ok_or_else(invalid_command)?)
670}
671
672fn command_name(command: &str) -> Result<()> {
673 valid_text(command)?;
674 let path = Path::new(command);
675 if !path.is_absolute()
676 && (path.components().count() != 1
677 || !matches!(
678 path.components().next(),
679 Some(std::path::Component::Normal(_))
680 ))
681 {
682 return Err(invalid_command());
683 }
684 Ok(())
685}
686
687fn valid_text(value: &str) -> Result<()> {
688 if value.is_empty() {
689 return Err(invalid_command());
690 }
691 valid_argument(value)
692}
693
694fn valid_argument(value: &str) -> Result<()> {
695 if value.len() > 4096 || value.chars().any(char::is_control) {
696 return Err(invalid_command());
697 }
698 Ok(())
699}
700
701fn invalid_command() -> Error {
702 Error::Config("invalid computer executable, argument, or path".into())
703}
704
705fn deadline(seconds: u64) -> Result<()> {
706 crate::config::bounded("computer deadline", seconds, 1..=3600)
707}
708
709fn dimensions([width, height]: [u32; 2]) -> Result<()> {
710 if !mobius::backend::session_files::ImagePresentation::default().fits(width, height) {
711 let limits = mobius::backend::session_files::ImagePresentation::default();
712 return Err(Error::Config(format!(
713 "computer capture dimensions must be positive and fit {} pixels on the longest side and {} patches of 32 × 32 pixels; reduce the viewport, window size or desktop resolution",
714 limits.max_dimension, limits.max_patches
715 )));
716 }
717 Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn manifest_defaults_borrow_immutable_storage() {
726 let defaults = ComputerConfig::default();
727 assert!(matches!(defaults.node_download_base_url, Cow::Borrowed(_)));
728 assert!(matches!(defaults.tar_executable, Cow::Borrowed(_)));
729 assert!(matches!(defaults.browser.arguments, Cow::Borrowed(_)));
730 assert!(matches!(defaults.browser.start_page, Cow::Borrowed(_)));
731 assert!(matches!(defaults.desktop.tools.xauth, Cow::Borrowed(_)));
732 assert!(matches!(defaults.desktop.tools.xvnc, Cow::Borrowed(_)));
733 assert!(matches!(defaults.desktop.tools.vncconfig, Cow::Borrowed(_)));
734 assert!(matches!(defaults.desktop.tools.setpriv, Cow::Borrowed(_)));
735 assert!(matches!(
736 defaults.desktop.branding.browser_label,
737 Cow::Borrowed(_)
738 ));
739 assert!(matches!(
740 defaults.desktop.branding.terminal_label,
741 Cow::Borrowed(_)
742 ));
743 assert!(matches!(
744 defaults.desktop.branding.files_label,
745 Cow::Borrowed(_)
746 ));
747 }
748
749 #[test]
750 fn mutating_a_borrowed_default_preserves_other_operator_configurations() {
751 let mut defaults = ComputerConfig::default();
752 defaults
753 .browser
754 .arguments
755 .to_mut()
756 .push("--mute-audio".into());
757 assert!(matches!(defaults.browser.arguments, Cow::Owned(_)));
758 assert!(
759 !ComputerConfig::default()
760 .browser
761 .arguments
762 .iter()
763 .any(|argument| argument == "--mute-audio")
764 );
765 }
766
767 #[test]
768 fn configured_values_own_their_storage_and_remain_editable() {
769 let mut operator: ComputerConfig = toml::from_str(
770 "[browser]\narguments = ['--mute-audio']\nstart_page = 'https://example.invalid'",
771 )
772 .unwrap();
773 assert!(matches!(operator.browser.arguments, Cow::Owned(_)));
774 assert!(matches!(operator.browser.start_page, Cow::Owned(_)));
775 operator.browser.arguments.to_mut().clear();
776 operator.browser.start_page.to_mut().push_str("/start");
777 operator.validate().unwrap();
778 }
779
780 #[test]
781 fn root_browser_requires_an_explicit_sandbox_opt_out() {
782 let mut browser = BrowserConfig::default();
783 let error = browser.validate_root_sandbox(true).unwrap_err();
784 assert!(
785 error
786 .to_string()
787 .contains("run the gateway as a non-root user")
788 );
789 assert!(
790 error
791 .to_string()
792 .contains("computer.browser.sandbox = false")
793 );
794 browser.validate_root_sandbox(false).unwrap();
795 browser.sandbox = false;
796 browser.validate_root_sandbox(true).unwrap();
797 }
798
799 #[test]
800 fn typed_defaults_preserve_pinned_runtime_and_desktop_policy() {
801 let defaults = ComputerConfig::default();
802 assert_eq!(defaults.install_timeout_seconds, 600);
803 assert_eq!(defaults.browser.window_size, [1200, 640]);
804 assert_eq!(defaults.browser.window_position, [80, 42]);
805 assert_eq!(defaults.browser.viewport, [1365, 768]);
806 assert_eq!(defaults.desktop.resolution, [1365, 768]);
807 assert_eq!(defaults.desktop.depth, 24);
808 assert_eq!(
809 (defaults.desktop.display_start, defaults.desktop.display_end),
810 (100, 200)
811 );
812 assert_eq!(defaults.desktop.tools.xvnc.as_ref(), ["Xtigervnc", "Xvnc"]);
813 assert_eq!(node_version(), "26.8.1");
814 assert_eq!(
815 node_distribution("linux", "aarch64").unwrap(),
816 (
817 "linux-arm64",
818 "d5f973ce975e4bd03e6c2038260f7e9201615aa8e1ee293c72f8dcc2a6d9fddb"
819 )
820 );
821 assert_eq!(
822 node_distribution("linux", "x86_64").unwrap(),
823 (
824 "linux-x64",
825 "b2b76660fa4ded4e0b2a41ee3c0c651cd52ea8170ead91ebac1e147ac3d55643"
826 )
827 );
828 assert_eq!(
829 node_distribution("macos", "aarch64").unwrap(),
830 (
831 "darwin-arm64",
832 "6e577fd0d9db776db82306629e441a9dace416702622aebdd171c9dfaa41f4d2"
833 )
834 );
835 assert_eq!(
836 node_distribution("macos", "x86_64").unwrap(),
837 (
838 "darwin-x64",
839 "fe9c6dbf9c8e1b4443803d75e2a20366e420dae650c747dbb116b22975751baf"
840 )
841 );
842 }
843
844 #[test]
845 fn embedded_defaults_require_operational_fields_without_public_default_recursion() {
846 let incomplete = DEFAULTS_TEXT.replace("viewport = [1365, 768]", "");
847 let error = toml::from_str::<ComputerDefaults>(&incomplete)
848 .err()
849 .unwrap();
850 assert!(error.to_string().contains("viewport"));
851 }
852
853 #[test]
854 fn partial_policy_keeps_manifest_defaults_and_requires_explicit_sandbox_disable() {
855 let mut config: ComputerConfig =
856 toml::from_str("[browser]\nviewport = [800, 600]\n").unwrap();
857 config.validate().unwrap();
858 assert_eq!(config.browser.viewport, [800, 600]);
859 assert!(
860 !config
861 .browser
862 .headed_arguments(Path::new("/tmp/profile"))
863 .contains(&"--no-sandbox".into())
864 );
865 config.browser.sandbox = false;
866 assert!(
867 config
868 .browser
869 .headed_arguments(Path::new("/tmp/profile"))
870 .contains(&"--no-sandbox".into())
871 );
872 }
873
874 #[test]
875 fn operator_arguments_cannot_replace_the_private_control_boundary() {
876 let mut config = ComputerConfig::default();
877 for argument in [
878 "--remote-debugging-address=0.0.0.0",
879 "--user-data-dir=/tmp/public",
880 "--no-sandbox",
881 "--remote-debugging-pipe",
882 ] {
883 config.browser.arguments = vec![argument.into()].into();
884 assert!(config.validate().is_err(), "{argument}");
885 }
886 }
887
888 #[test]
889 fn disabled_or_missing_default_application_is_optional() {
890 let application = DesktopApplication {
891 enabled: false,
892 ..DesktopApplication::default()
893 };
894 for role in [
895 ApplicationRole::WindowManager,
896 ApplicationRole::Panel,
897 ApplicationRole::Wallpaper,
898 ApplicationRole::Terminal,
899 ApplicationRole::Files,
900 ] {
901 assert!(super::application(&application, role).unwrap().is_none());
902 }
903 assert!(resolve_executable(Path::new("relative/path")).is_err());
904 }
905}