1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// linux_docs -- the documents portal (org.freedesktop.portal.Documents) over linux_dbus.rs
//
// A Flatpak app gets the files the user hands it (the portal's file chooser, a drop, "open
// with") as documents of the portal: below its FUSE mount, $XDG_RUNTIME_DIR/doc/ID/NAME
// (/run/user/UID/doc/...), ordinary OS paths. The file chooser exports lasting documents (the
// ID stays with the app's permissions across log-outs and reboots); flatpak's file forwarding
// ("open with") and the file transfer portal make documents for the session only.
// persistent() asks the portal for the lasting document of a path below the mount:
// Documents.Add(O_PATH descriptor, reuse_existing, persistent) -> an ID, the same one when the
// same file is asked again. For a descriptor on its own mount the portal answers that
// document's ID (xdg-document-portal 1.18, Documents version 4: it refuses reuse_existing
// false), so a session document cannot be made lasting from inside the sandbox; where the
// portal reaches the real file the answer is a lasting document of it.
//
// persistent(path) -> Option<String> <mount>/<ID of the answer>/<NAME> for a file document
// below the mount; None for any other path, a folder or a file inside an exported
// folder, no session bus / portal / descriptor passing, or the portal's refusal
// sandboxed(path) -> bool inside a Flatpak sandbox, below the mount (policy.rs: such
// documents are the user's, allowed with fs.user like Android's content://, so a lasting
// one opens again after a restart without a grant)
//
// The mount is $XDG_RUNTIME_DIR/doc (else /run/user/UID/doc); persistent() checks it against
// the portal's GetMountPoint once per process. Tested against a fake portal on a private bus
// (tools/platshared/docs_bus.py, plattest --test docs-bus) and with --real against
// xdg-document-portal and xdg-desktop-portal's file chooser for an app in a bwrap sandbox.
use alloc::format;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use rt::os::{syscall6, AT_FDCWD, O_CLOEXEC, O_PATH, SYS_CLOSE, SYS_GETUID, SYS_OPENAT};
use rt::sync::SpinLock;
use crate::linux_dbus::{Conn, Msg, Value};
const T_PORTAL: i32 = 5_000; // ms until the portal answers (it may be started first)
const NAME: &str = "org.freedesktop.portal.Documents";
const PATH: &str = "/org/freedesktop/portal/documents";
// the portal's mount point, asked once (Some(None): no portal)
static MOUNT: SpinLock<Option<Option<String>>> = SpinLock::new(None);
// where the portal mounts its documents: $XDG_RUNTIME_DIR/doc, else /run/user/UID/doc
fn base() -> String {
match crate::env("XDG_RUNTIME_DIR").filter(|d| d.starts_with('/')) {
Some(d) => format!("{}/doc", d.trim_end_matches('/')),
// SAFETY: getuid has no arguments.
None => format!("/run/user/{}/doc", unsafe { syscall6(SYS_GETUID, 0, 0, 0, 0, 0, 0) }),
}
}
// (ID, NAME) of a file document's path below the mount
fn split(path: &str) -> Option<(&str, &str)> {
let b = base();
let rest = path.strip_prefix(b.as_str())?.strip_prefix('/')?;
let (id, name) = rest.split_once('/')?;
let id_ok = !id.is_empty() && id.bytes().all(|c| c.is_ascii_alphanumeric());
(id_ok && !name.is_empty() && !name.contains('/')).then_some((id, name))
}
/// see the file header
pub(crate) fn sandboxed(path: &str) -> bool {
path.strip_prefix(base().as_str()).is_some_and(|r| r.starts_with('/')) && crate::linux_desk::in_flatpak()
}
// the portal's mount point (GetMountPoint -> ay), asked once (until it answers)
fn mount(c: &mut Conn) -> Option<String> {
let mut g = MOUNT.lock();
if let Some(m) = g.as_ref() {
return m.clone();
}
// no answer at all (the portal is still starting): not kept, asked again next time
let r = crate::linux_desk::call(c, &Msg::call(NAME, PATH, NAME, "GetMountPoint", Vec::new()), T_PORTAL)?;
let m = Some(r).and_then(|r| match r.body.first() {
Some(Value::A(_, bytes)) => {
let b: Vec<u8> = bytes.iter().filter_map(|v| if let Value::Y(c) = v { Some(*c) } else { None }).take_while(|&c| c != 0).collect();
String::from_utf8(b).ok().filter(|s| s.starts_with('/'))
}
_ => None,
});
*g = Some(m.clone());
m
}
/// see the file header
pub(crate) fn persistent(path: &str) -> Option<String> {
let (_, name) = split(path)?;
let mut c = Conn::session_fds()?;
if !c.fds_ok || mount(&mut c)?.trim_end_matches('/') != base() {
return None;
}
let mut z = Vec::from(path.as_bytes());
z.push(0);
// SAFETY: openat with a NUL-terminated path; the descriptor is closed below.
let fd = unsafe { syscall6(SYS_OPENAT, AT_FDCWD, z.as_ptr() as usize, O_PATH | O_CLOEXEC, 0, 0, 0) };
if fd < 0 {
return None;
}
// Add(h o_path_fd, b reuse_existing, b persistent) -> s doc_id
let mut m = Msg::call(NAME, PATH, NAME, "Add", vec![Value::H(0), Value::B(true), Value::B(true)]);
m.fds = vec![fd as i32];
let r = crate::linux_desk::call(&mut c, &m, T_PORTAL);
// SAFETY: our descriptor, closed once (the bus copied it).
unsafe { syscall6(SYS_CLOSE, fd as usize, 0, 0, 0, 0, 0) };
let id = String::from(r?.body.first()?.as_str()?);
if id.is_empty() || !id.bytes().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(format!("{}/{id}/{name}", base()))
}