#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
const FLATPAK_INFO: &str = "/.flatpak-info";
const SESSION_BUS_POLICY: &str = "Session Bus Policy";
const DCONF_BUS_NAME: &str = "ca.desrt.dconf";
const DCONF_POLICY_TALK: &str = "talk";
const SNAP_DIR: &str = "SNAP";
const SNAP_YAML: &str = "meta/snap.yaml";
const CONFINEMENT_PREFIX: &str = "confinement:";
const CONFINEMENT_CLASSIC: &str = "classic";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Sandbox {
None,
Flatpak {
dconf_access: bool,
},
Snap,
}
impl Sandbox {
pub(crate) fn gsettings_is_trustworthy(self) -> bool {
match self {
Sandbox::None => true,
Sandbox::Flatpak { dconf_access } => dconf_access,
Sandbox::Snap => false,
}
}
pub(crate) fn name(self) -> &'static str {
match self {
Sandbox::None => "unsandboxed",
Sandbox::Flatpak { .. } => "Flatpak",
Sandbox::Snap => "Snap",
}
}
}
pub(crate) fn detect(env: &super::Env) -> Sandbox {
sandbox_from_flatpak_read(std::fs::read_to_string(FLATPAK_INFO), || is_snap(env))
}
fn sandbox_from_flatpak_read(
read: std::io::Result<String>,
is_snap: impl FnOnce() -> bool,
) -> Sandbox {
match read {
Ok(text) => Sandbox::Flatpak {
dconf_access: has_dconf_access(&text),
},
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Sandbox::Flatpak {
dconf_access: false,
},
Err(_) if is_snap() => Sandbox::Snap,
Err(_) => Sandbox::None,
}
}
fn is_snap(env: &super::Env) -> bool {
let Some(snap_dir) = env.var_os(SNAP_DIR) else {
return false;
};
let manifest = std::path::Path::new(&snap_dir).join(SNAP_YAML);
let Ok(yaml) = std::fs::read_to_string(&manifest) else {
return false;
};
snap_is_confined(&yaml)
}
fn snap_is_confined(yaml: &str) -> bool {
snap_confinement(yaml) != Some(CONFINEMENT_CLASSIC)
}
fn snap_confinement(yaml: &str) -> Option<&str> {
yaml.lines()
.find_map(|line| line.strip_prefix(CONFINEMENT_PREFIX))
.map(str::trim_ascii)
}
pub(crate) fn has_dconf_access(flatpak_info: &str) -> bool {
let mut in_section = false;
let mut in_group = false;
let mut granted = false;
for line in flatpak_info.lines() {
let line = line.trim_ascii_start();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let Some(name) = group_name(rest) else {
return false;
};
in_section = name == SESSION_BUS_POLICY;
in_group = true;
continue;
}
let Some((key, value)) = line.split_once('=') else {
return false;
};
if !in_group {
return false;
}
let key = key.trim_ascii_end();
if key.is_empty() {
return false;
}
if key.bytes().any(|byte| byte == b'[' || byte == b']') {
return false;
}
if in_section && key == DCONF_BUS_NAME {
granted = value.trim_ascii_start() == DCONF_POLICY_TALK;
}
}
granted
}
fn group_name(after_bracket: &str) -> Option<&str> {
let (name, rest) = after_bracket.split_once(']')?;
if !rest.bytes().all(|byte| byte == b' ' || byte == b'\t') {
return None;
}
if name.is_empty()
|| name
.bytes()
.any(|byte| byte == b'[' || byte.is_ascii_control())
{
return None;
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
const TEMPLATE: &str = "\
[Application]
name=org.example.App
runtime=runtime/org.gnome.Platform/x86_64/45
[Context]
shared=network;ipc;
sockets=x11;wayland;
[Session Bus Policy]
org.freedesktop.Notifications=talk
{DCONF}
[Instance]
instance-id=1234567890
";
fn info(dconf_line: &str) -> String {
TEMPLATE.replace("{DCONF}", dconf_line)
}
#[test]
fn a_talk_policy_grants_dconf_access() {
assert!(has_dconf_access(&info("ca.desrt.dconf=talk")));
assert!(has_dconf_access(&info(" ca.desrt.dconf = talk")));
assert!(has_dconf_access(&info("# a comment\nca.desrt.dconf=talk")));
}
#[test]
fn a_missing_or_weaker_policy_does_not() {
assert!(!has_dconf_access(&info("")));
assert!(!has_dconf_access(&info("ca.desrt.dconf=see")));
assert!(!has_dconf_access(&info("ca.desrt.dconf=none")));
assert!(!has_dconf_access(""));
}
#[test]
fn a_policy_glib_itself_would_reject_does_not_grant_access() {
assert!(
!has_dconf_access(&info("ca.desrt.dconf=own")),
"`own` implies `talk` on the bus, and GLib still does not compare against it"
);
assert!(
!has_dconf_access(&info("ca.desrt.dconf=talk ")),
"GKeyFile keeps a value's trailing whitespace, so `strcmp` fails on this"
);
assert!(
!has_dconf_access(&info("ca.desrt.dconf=Talk")),
"`strcmp` is case sensitive"
);
assert!(
!has_dconf_access(&info("ca.desrt.dconf=\u{a0}talk")),
"GKeyFile chugs with `g_ascii_isspace`, which is ASCII and locale independent"
);
}
#[test]
fn duplicate_dconf_keys_are_last_wins() {
assert!(!has_dconf_access(&info(
"ca.desrt.dconf=talk\nca.desrt.dconf=none"
)));
assert!(has_dconf_access(&info(
"ca.desrt.dconf=none\nca.desrt.dconf=talk"
)));
}
#[test]
fn the_policy_only_counts_inside_its_own_section() {
let text = "\
[Session Bus Policy]
org.freedesktop.Notifications=talk
[Environment]
ca.desrt.dconf=talk
";
assert!(
!has_dconf_access(text),
"a key outside [Session Bus Policy] must not grant access"
);
}
#[test]
fn a_line_gkeyfile_would_reject_denies_access() {
assert!(
!has_dconf_access(&info("ca.desrt.dconf=talk\nnot a key-value pair")),
"a line that is neither comment, group nor `key=value` is G_KEY_FILE_ERROR_PARSE"
);
assert!(
!has_dconf_access(&info("; a comment\nca.desrt.dconf=talk")),
"`g_key_file_line_is_comment` is `#` and the blank line only; `;` opens a key"
);
assert!(
!has_dconf_access(&info("=talk\nca.desrt.dconf=talk")),
"\"No empty keys, please\""
);
assert!(
!has_dconf_access("[]\n\n[Session Bus Policy]\nca.desrt.dconf=talk\n"),
"`g_key_file_is_group_name` wants a non-empty name"
);
assert!(
!has_dconf_access("[App\u{1}]\n\n[Session Bus Policy]\nca.desrt.dconf=talk\n"),
"and rejects an ASCII control character inside one"
);
assert!(
!has_dconf_access("[App[1]\n\n[Session Bus Policy]\nca.desrt.dconf=talk\n"),
"and a `[` inside one, which no locale suffix can excuse in a group name"
);
assert!(
!has_dconf_access("ca.desrt.dconf=talk\n[Session Bus Policy]\nca.desrt.dconf=talk\n"),
"\"Key file does not start with a group\""
);
assert!(
!has_dconf_access("[Session Bus Policy\nca.desrt.dconf=talk\n"),
"`g_key_file_line_is_group` wants the closing `]`"
);
assert!(
!has_dconf_access("[Session Bus Policy] x\nca.desrt.dconf=talk\n"),
"only spaces and tabs may follow the `]`"
);
assert!(
!has_dconf_access(
"[Application]\nbad]key=v\n\n[Session Bus Policy]\nca.desrt.dconf=talk\n"
),
"`g_key_file_is_key_name` rejects a `]` outside a locale suffix"
);
assert!(
!has_dconf_access(&info("bad[key=v\nca.desrt.dconf=talk")),
"and rejects a `[` that opens no locale suffix"
);
}
#[test]
fn a_group_header_is_taken_exactly_as_written() {
assert!(!has_dconf_access(
"[ Session Bus Policy ]\nca.desrt.dconf=talk\n"
));
assert!(has_dconf_access(
"[Session Bus Policy] \t\nca.desrt.dconf=talk\n"
));
}
const SNAP_YAML_TEMPLATE: &str = "\
name: example
version: '1.0'
summary: An example snap
{CONFINEMENT}
grade: stable
";
fn snap_yaml(confinement_line: &str) -> String {
SNAP_YAML_TEMPLATE.replace("{CONFINEMENT}", confinement_line)
}
#[test]
fn the_confinement_line_is_read_the_way_glib_reads_it() {
assert_eq!(
snap_confinement(&snap_yaml("confinement: strict")),
Some("strict")
);
assert_eq!(
snap_confinement(&snap_yaml("confinement: classic ")),
Some(CONFINEMENT_CLASSIC)
);
assert_eq!(snap_confinement(&snap_yaml("grade: devel")), None);
assert_eq!(snap_confinement(""), None);
}
#[test]
fn an_indented_confinement_line_is_not_the_confinement_line() {
assert_eq!(snap_confinement(&snap_yaml(" confinement: classic")), None);
}
#[test]
fn the_first_confinement_line_wins() {
assert_eq!(
snap_confinement("confinement: strict\nconfinement: classic\n"),
Some("strict"),
"GLib breaks out of its scan at the first match"
);
}
#[test]
fn a_manifest_naming_no_confinement_is_still_confined() {
assert!(snap_is_confined(&snap_yaml("grade: devel")));
assert!(snap_is_confined(""));
assert!(snap_is_confined(&snap_yaml("confinement: strict")));
assert!(!snap_is_confined(&snap_yaml("confinement: classic")));
}
#[test]
fn gsettings_trust_follows_the_policy() {
assert!(Sandbox::None.gsettings_is_trustworthy());
assert!(Sandbox::Flatpak { dconf_access: true }.gsettings_is_trustworthy());
assert!(
!Sandbox::Flatpak {
dconf_access: false
}
.gsettings_is_trustworthy(),
"this is the silent `mode = none` case that must never reach the user"
);
assert!(!Sandbox::Snap.gsettings_is_trustworthy());
}
#[test]
fn an_unreadable_flatpak_info_denies_dconf_access_instead_of_looking_unsandboxed() {
let invalid_utf8 = std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
);
assert_eq!(
sandbox_from_flatpak_read(Err(invalid_utf8), || false),
Sandbox::Flatpak {
dconf_access: false
}
);
let permission_denied = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert_eq!(
sandbox_from_flatpak_read(Err(permission_denied), || true),
Sandbox::Flatpak {
dconf_access: false
},
"a real Flatpak sandbox, not the snap check, decides this even when is_snap() would say yes"
);
}
#[test]
fn a_missing_flatpak_info_falls_through_to_the_snap_check() {
let not_found = || std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
sandbox_from_flatpak_read(Err(not_found()), || false),
Sandbox::None
);
assert_eq!(
sandbox_from_flatpak_read(Err(not_found()), || true),
Sandbox::Snap
);
}
}