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

+6 -1
View File
@@ -24,6 +24,11 @@ standard file, print and application dialogs. Install both backends.
current frontends manage those files themselves. Direct sessions launch standard
XDG autostart entries after importing the display environment. User overrides,
Hidden, OnlyShowIn, NotShowIn and TryExec are respected; previews skip autostart.
- **Lockdown:** standard printing, save-to-disk, application-handler, location,
camera, microphone and sound-output restrictions. All default to unrestricted.
Configure boolean `disable-*` fields under `portals { lockdown { ... } }`;
changes are signalled to the frontend. Invalid policy types retain the current
policy. Only the portal frontend may write backend policy properties.
- **USB:** native confirmation showing device vendor/model and requested read-only
or read/write access. The portal frontend handles device enumeration and opening.
- **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
@@ -58,7 +63,7 @@ Matching D-Bus signatures does not establish complete feature parity.
| RemoteDesktop | Missing authorized input injection and EIS transport |
| Clipboard | Missing remote-session clipboard transfer |
| InputCapture | Missing zones, pointer barriers and EIS transport |
| Lockdown | GTK availability depends on the installed build; no native policy provider |
| Lockdown | Native seven-property policy provider with persisted configuration and change signals |
| Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition |
The frontend supplies additional APIs such as OpenURI, network monitoring,
+1
View File
@@ -6,3 +6,4 @@ org.freedesktop.impl.portal.Screenshot=ferese;
org.freedesktop.impl.portal.Wallpaper=ferese;
org.freedesktop.impl.portal.Background=ferese;
org.freedesktop.impl.portal.Usb=ferese;
org.freedesktop.impl.portal.Lockdown=ferese;
+1 -1
View File
@@ -1,4 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;
UseIn=Ferese;
+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;
+34 -1
View File
@@ -54,7 +54,7 @@ def private_bus_checks():
raise AssertionError((root / "backend.log").read_text())
time.sleep(0.05)
node = ET.fromstring(xml)
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb"):
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown"):
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, name
expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
@@ -65,6 +65,7 @@ def private_bus_checks():
assert actual is not None, (name, member.tag, member.attrib["name"])
if member.tag == "property":
assert actual.attrib["type"] == member.attrib["type"]
assert actual.attrib["access"] == member.attrib["access"]
else:
def types(element, direction):
return [arg.attrib["type"] for arg in element.findall("arg")
@@ -89,6 +90,14 @@ def private_bus_checks():
raise AssertionError("Untrusted backend caller succeeded")
except GLib.Error:
pass
original_config = config.read_text()
try:
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True))))
raise AssertionError("Untrusted policy writer succeeded")
except GLib.Error as error:
assert "AccessDenied" in str(error), error
printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)")
assert printing is False and config.read_text() == original_config
request_name = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
@@ -96,6 +105,30 @@ def private_bus_checks():
response, values = call("org.freedesktop.impl.portal.Screenshot", "Screenshot", params, "(ua{sv})")
assert response == 2 and values == {}
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True))))
printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)")
assert printing is True
assert "disable-printing #true" in config.read_text()
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", False))))
policy_signals = []
subscription = bus.signal_subscribe(NAME, "org.freedesktop.DBus.Properties", "PropertiesChanged", PATH,
None, Gio.DBusSignalFlags.NONE, lambda *args: policy_signals.append(args[-1].unpack()))
valid_policy = config.read_text().replace("disable-printing #false", "disable-printing #true")
config.write_text(valid_policy)
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not any(s[0] == "org.freedesktop.impl.portal.Lockdown" and s[1].get("disable-printing") is True for s in policy_signals):
GLib.MainContext.default().iteration(False)
time.sleep(0.02)
assert any(s[0] == "org.freedesktop.impl.portal.Lockdown" and s[1].get("disable-printing") is True for s in policy_signals), policy_signals
for invalid_policy in ('portals { lockdown { disable-printing "bad"; }; }', 'portals { lockdown "bad"; }', 'portals #false'):
config.write_text(original_config + "\n" + invalid_policy)
time.sleep(1.2)
printing, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing")), "(v)")
assert printing is True, invalid_policy
config.write_text(valid_policy)
bus.signal_unsubscribe(subscription)
usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""),
(PATH + "/request/test/usb", "", "org.test.App", [], {}))
response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})")