Add native portal lockdown policy and serialize configuration writes
This commit is contained in:
1 parent
09a8cd346a
commit
e71cd88ff8
8 files changed
+318
-4
No files matched your search
+6
-1
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod backend;
|
||||
mod capture;
|
||||
mod consent;
|
||||
mod desktop;
|
||||
mod lockdown;
|
||||
mod parent;
|
||||
mod permissions;
|
||||
mod picker;
|
||||
|
||||
@@ -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})")
|
||||
|
||||
Reference in new issue
Block a user