Add native portal lockdown policy and serialize configuration writes

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:10:50 +01:00
1 parent 09a8cd346a
commit e71cd88ff8
8 files changed
+318 -4

No files matched your search

+10 -1
View File
@@ -26,6 +26,9 @@ use zbus::{
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug)]
enum StartError {
Cancelled,
@@ -154,7 +157,10 @@ pub(crate) async fn authorize(
.sender()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
let owner = dbus
.get_name_owner(FRONTEND.try_into().unwrap())
.await
.map_err(|_| zbus::fdo::Error::AccessDenied("Desktop portal is unavailable".into()))?;
if sender.as_str() != owner.as_str() {
return Err(zbus::fdo::Error::AccessDenied(
"Use the desktop portal".into(),
@@ -661,6 +667,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let settings = crate::settings::Settings::new();
let requests = crate::desktop::Requests::default();
let background = crate::permissions::Background::new(requests.clone());
let lockdown = crate::lockdown::Lockdown::new();
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())?
@@ -669,6 +676,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))?
.serve_at(PATH, crate::permissions::Usb(requests.clone()))?
.serve_at(PATH, background.clone())?
.serve_at(PATH, lockdown.clone())?
.serve_at(
"/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()),
@@ -677,6 +685,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await?;
tokio::spawn(settings.watch(connection.clone()));
tokio::spawn(background.watch(connection.clone()));
tokio::spawn(lockdown.watch(connection.clone()));
// Frontend death must revoke every stream, even if Session.Close never arrives.
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
+3
View File
@@ -580,6 +580,9 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTem
}
fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> {
let _transaction = crate::backend::CONFIG_TRANSACTION
.lock()
.map_err(|_| "Configuration transaction failed")?;
let path = ferese_config::config_path().ok_or("Missing config path")?;
edit_wallpaper(&path, image, target)
}
+262
View File
@@ -0,0 +1,262 @@
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use zbus::{Connection, message::Header, object_server::SignalEmitter, zvariant::Value};
const PATH: &str = "/org/freedesktop/portal/desktop";
const KEYS: [&str; 7] = [
"disable-printing",
"disable-save-to-disk",
"disable-application-handlers",
"disable-location",
"disable-camera",
"disable-microphone",
"disable-sound-output",
];
#[derive(Clone)]
pub(crate) struct Lockdown(Arc<RwLock<[bool; 7]>>);
impl Lockdown {
pub(crate) fn new() -> Self {
Self(Arc::new(RwLock::new(load().unwrap_or_default())))
}
pub(crate) async fn watch(self, connection: Connection) {
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let mut current = self.0.write().await;
let Some(next) = load() else { continue };
if *current == next {
continue;
}
let changed = KEYS
.iter()
.enumerate()
.filter(|(index, _)| current[*index] != next[*index])
.map(|(index, key)| (*key, Value::from(next[index])))
.collect::<HashMap<_, _>>();
*current = next;
drop(current);
let _ = zbus::fdo::Properties::properties_changed(
&emitter,
"org.freedesktop.impl.portal.Lockdown".try_into().unwrap(),
changed,
Cow::Borrowed(&[]),
)
.await;
}
}
async fn set(
&self,
index: usize,
value: bool,
connection: &Connection,
header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
let header =
header.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing policy caller".into()))?;
crate::backend::authorize(connection, &header).await?;
let mut current = self.0.write().await;
store(index, value).map_err(zbus::fdo::Error::Failed)?;
current[index] = value;
Ok(())
}
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Lockdown")]
impl Lockdown {
#[zbus(property, name = "disable-printing")]
async fn disable_printing(&self) -> bool {
self.0.read().await[0]
}
#[zbus(property, name = "disable-printing")]
async fn set_disable_printing(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(0, value, connection, header).await
}
#[zbus(property, name = "disable-save-to-disk")]
async fn disable_save_to_disk(&self) -> bool {
self.0.read().await[1]
}
#[zbus(property, name = "disable-save-to-disk")]
async fn set_disable_save_to_disk(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(1, value, connection, header).await
}
#[zbus(property, name = "disable-application-handlers")]
async fn disable_application_handlers(&self) -> bool {
self.0.read().await[2]
}
#[zbus(property, name = "disable-application-handlers")]
async fn set_disable_application_handlers(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(2, value, connection, header).await
}
#[zbus(property, name = "disable-location")]
async fn disable_location(&self) -> bool {
self.0.read().await[3]
}
#[zbus(property, name = "disable-location")]
async fn set_disable_location(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(3, value, connection, header).await
}
#[zbus(property, name = "disable-camera")]
async fn disable_camera(&self) -> bool {
self.0.read().await[4]
}
#[zbus(property, name = "disable-camera")]
async fn set_disable_camera(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(4, value, connection, header).await
}
#[zbus(property, name = "disable-microphone")]
async fn disable_microphone(&self) -> bool {
self.0.read().await[5]
}
#[zbus(property, name = "disable-microphone")]
async fn set_disable_microphone(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(5, value, connection, header).await
}
#[zbus(property, name = "disable-sound-output")]
async fn disable_sound_output(&self) -> bool {
self.0.read().await[6]
}
#[zbus(property, name = "disable-sound-output")]
async fn set_disable_sound_output(
&self,
value: bool,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
self.set(6, value, connection, header).await
}
}
fn values(document: &ferese_config::Document) -> Option<[bool; 7]> {
for path in ["portals", "portals.lockdown"] {
if document.get(path).is_some_and(|value| !value.is_object()) {
return None;
}
}
let mut values = [false; 7];
for (index, key) in KEYS.iter().enumerate() {
if let Some(value) = document.get(&format!("portals.lockdown.{}", key.replace('-', "_"))) {
values[index] = value.as_bool()?;
}
}
Some(values)
}
fn load() -> Option<[bool; 7]> {
let path = ferese_config::config_path()?;
let source = match std::fs::read_to_string(path) {
Ok(source) if source.len() <= 1024 * 1024 => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
_ => return None,
};
values(&ferese_config::Document::parse(&source).ok()?)
}
fn store(index: usize, value: bool) -> Result<(), String> {
let _transaction = crate::backend::CONFIG_TRANSACTION
.lock()
.map_err(|_| "Configuration transaction failed")?;
let path = ferese_config::config_path().ok_or("Missing config directory")?;
let source = match std::fs::read_to_string(&path) {
Ok(source) if source.len() <= 1024 * 1024 => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.to_string()),
_ => return Err("Configuration is too large".into()),
};
let mut document =
ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
if values(&document).is_none() {
return Err("Invalid lockdown policy".into());
}
document
.set(
&format!("portals.lockdown.{}", KEYS[index].replace('-', "_")),
value.into(),
)
.map_err(|error| error.to_string())?;
let directory = path.parent().ok_or("Invalid config path")?;
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, document.to_string().as_bytes())
.map_err(|error| error.to_string())?;
file.as_file()
.sync_all()
.map_err(|error| error.to_string())?;
if std::fs::read_to_string(&path).unwrap_or_default() != source {
return Err("Configuration changed; please try again".into());
}
file.persist(path).map_err(|error| error.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_is_unrestricted_and_wrong_types_are_rejected() {
let empty = ferese_config::Document::parse("").unwrap();
assert_eq!(values(&empty), Some([false; 7]));
let enabled = ferese_config::Document::parse(
"portals { lockdown { disable-camera #true; disable-save-to-disk #true; }; }",
)
.unwrap();
let values = values(&enabled).unwrap();
assert!(values[1] && values[4]);
assert!(!values[0]);
let invalid =
ferese_config::Document::parse("portals { lockdown { disable-camera \"yes\"; }; }")
.unwrap();
assert!(super::values(&invalid).is_none());
for source in ["portals #false", "portals { lockdown \"bad\"; }"] {
let invalid = ferese_config::Document::parse(source).unwrap();
assert!(super::values(&invalid).is_none());
}
}
}
+1
View File
@@ -2,6 +2,7 @@ mod backend;
mod capture;
mod consent;
mod desktop;
mod lockdown;
mod parent;
mod permissions;
mod picker;