1
// org.freedesktop.Secret.Session
2

            
3
use std::{
4
    fmt,
5
    sync::Arc,
6
    time::{Duration, Instant},
7
};
8

            
9
use oo7::{Key, dbus::ServiceError};
10
use tokio::sync::Mutex;
11
use zbus::{
12
    interface,
13
    names::UniqueName,
14
    zvariant::{ObjectPath, OwnedObjectPath},
15
};
16

            
17
use crate::Service;
18

            
19
const SESSION_STALE_TIMEOUT: Duration = Duration::from_secs(60);
20

            
21
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22
pub enum SessionType {
23
    X11,
24
    Wayland,
25
    Tty,
26
    Unspecified,
27
}
28

            
29
impl SessionType {
30
    pub fn is_graphical(self) -> bool {
31
        matches!(self, Self::X11 | Self::Wayland)
32
    }
33

            
34
    pub async fn from_logind(pid: u32) -> Option<Self> {
35
        let connection = zbus::Connection::system().await.ok()?;
36
        let manager = LoginManagerProxy::new(&connection).await.ok()?;
37
        let session_path = manager.get_session_by_pid(pid).await.ok()?;
38
        let session = LoginSessionProxy::builder(&connection)
39
            .path(session_path)
40
            .ok()?
41
            .build()
42
            .await
43
            .ok()?;
44
        let type_str = session.type_().await.ok()?;
45
        Some(match type_str.as_str() {
46
            "x11" => Self::X11,
47
            "wayland" => Self::Wayland,
48
            "tty" => Self::Tty,
49
            _ => Self::Unspecified,
50
        })
51
    }
52

            
53
    /// Fall back to the peer's own environment, for compositors run as a
54
    /// systemd `--user` service (e.g. niri, sway), which `logind` can't
55
    /// place in a session at all. Often blocked by Yama's `ptrace_scope`,
56
    /// since the daemon isn't an ancestor of its peers; see
57
    /// [`Self::from_systemd_user_environment`] for the last resort.
58
23
    async fn from_environ(pid: u32) -> Option<Self> {
59
17
        let environ = tokio::fs::read(format!("/proc/{pid}/environ")).await.ok()?;
60
22
        Self::from_display_vars(environ.split(|&b| b == 0))
61
    }
62

            
63
    /// Last resort: the systemd `--user` manager's own exported
64
    /// environment. Systemd-integrated compositors import
65
    /// `WAYLAND_DISPLAY`/`DISPLAY` into it on startup (e.g. via
66
    /// `dbus-update-activation-environment`), and it's readable over
67
    /// D-Bus with no `ptrace_scope` restriction. Coarser than the other
68
    /// checks since it's session-wide rather than peer-specific, so it's
69
    /// only consulted once `logind` can't place the peer anywhere.
70
    async fn from_systemd_user_environment() -> Option<Self> {
71
        let connection = zbus::Connection::session().await.ok()?;
72
        let manager = SystemdManagerProxy::new(&connection).await.ok()?;
73
        let environment = manager.environment().await.ok()?;
74

            
75
        Self::from_display_vars(environment.iter().map(String::as_bytes))
76
    }
77

            
78
    /// Shared `WAYLAND_DISPLAY`/`DISPLAY` lookup over a set of `NAME=value`
79
    /// entries, as found in both `/proc/<pid>/environ` and the systemd
80
    /// `--user` manager's exported environment.
81
13
    fn from_display_vars<'a>(vars: impl Iterator<Item = &'a [u8]>) -> Option<Self> {
82
15
        let mut wayland = false;
83
16
        let mut x11 = false;
84
25
        for entry in vars {
85
35
            if let Some(value) = entry.strip_prefix(b"WAYLAND_DISPLAY=") {
86
16
                wayland |= !value.is_empty();
87
32
            } else if let Some(value) = entry.strip_prefix(b"DISPLAY=") {
88
8
                x11 |= !value.is_empty();
89
            }
90
        }
91

            
92
20
        if wayland {
93
8
            Some(Self::Wayland)
94
25
        } else if x11 {
95
8
            Some(Self::X11)
96
        } else {
97
12
            None
98
        }
99
    }
100

            
101
    /// Best-effort session type detection, cascading `logind` ->
102
    /// [`Self::from_environ`] -> [`Self::from_systemd_user_environment`].
103
    /// Stops at the first check that places the peer in a session at
104
    /// all, even a non-graphical one.
105
    pub async fn detect(pid: u32) -> Self {
106
        if let Some(session_type) = Self::from_logind(pid).await {
107
            return session_type;
108
        }
109

            
110
        if let Some(session_type) = Self::from_environ(pid).await {
111
            return session_type;
112
        }
113

            
114
        Self::from_systemd_user_environment()
115
            .await
116
            .unwrap_or(Self::Unspecified)
117
    }
118
}
119

            
120
#[zbus::proxy(
121
    default_service = "org.freedesktop.login1",
122
    interface = "org.freedesktop.login1.Manager",
123
    default_path = "/org/freedesktop/login1",
124
    gen_blocking = false
125
)]
126
trait LoginManager {
127
    fn get_session_by_pid(&self, pid: u32) -> zbus::Result<OwnedObjectPath>;
128
}
129

            
130
#[zbus::proxy(
131
    default_service = "org.freedesktop.login1",
132
    interface = "org.freedesktop.login1.Session",
133
    gen_blocking = false
134
)]
135
trait LoginSession {
136
    #[zbus(property)]
137
    fn type_(&self) -> zbus::Result<String>;
138
}
139

            
140
#[zbus::proxy(
141
    default_service = "org.freedesktop.systemd1",
142
    interface = "org.freedesktop.systemd1.Manager",
143
    default_path = "/org/freedesktop/systemd1",
144
    gen_blocking = false
145
)]
146
trait SystemdManager {
147
    #[zbus(property)]
148
    fn environment(&self) -> zbus::Result<Vec<String>>;
149
}
150

            
151
#[derive(Debug, Clone)]
152
pub struct PeerInfo {
153
    pid: u32,
154
    name: String,
155
    session_type: SessionType,
156
}
157

            
158
impl PeerInfo {
159
    pub fn new(pid: u32, name: String, session_type: SessionType) -> Self {
160
        Self {
161
            pid,
162
            name,
163
            session_type,
164
        }
165
    }
166

            
167
    pub fn session_type(&self) -> SessionType {
168
        self.session_type
169
    }
170
}
171

            
172
impl fmt::Display for PeerInfo {
173
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174
        write!(f, "{}[{}]", self.name, self.pid)
175
    }
176
}
177

            
178
#[derive(Clone)]
179
pub struct Session {
180
    aes_key: Option<Arc<Key>>,
181
    service: Service,
182
    path: OwnedObjectPath,
183
    sender: UniqueName<'static>,
184
    peer_info: Option<PeerInfo>,
185
    disconnected_at: Arc<Mutex<Option<Instant>>>,
186
}
187

            
188
#[interface(name = "org.freedesktop.Secret.Session")]
189
impl Session {
190
24
    pub async fn close(&self) -> Result<(), ServiceError> {
191
12
        tracing::info!("Closing session {} for {}.", self.path, self.sender);
192
12
        self.service.remove_session(&self.path).await;
193
30
        self.service
194
            .object_server()
195
6
            .remove::<Self, _>(&self.path)
196
18
            .await?;
197

            
198
6
        Ok(())
199
    }
200
}
201

            
202
impl Session {
203
31
    pub async fn new(
204
        aes_key: Option<Arc<Key>>,
205
        service: Service,
206
        sender: UniqueName<'static>,
207
        peer_info: Option<PeerInfo>,
208
    ) -> Self {
209
56
        let index = service.session_index();
210
        Self {
211
25
            path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/session/s{index}"))
212
                .unwrap(),
213
            aes_key,
214
            service,
215
            sender,
216
            peer_info,
217
54
            disconnected_at: Arc::new(Mutex::new(None)),
218
        }
219
    }
220

            
221
    pub fn sender(&self) -> &UniqueName<'static> {
222
        &self.sender
223
    }
224

            
225
32
    pub fn peer_info(&self) -> Option<&PeerInfo> {
226
26
        self.peer_info.as_ref()
227
    }
228

            
229
33
    pub fn path(&self) -> &ObjectPath<'_> {
230
29
        &self.path
231
    }
232

            
233
19
    pub fn aes_key(&self) -> Option<Arc<Key>> {
234
18
        self.aes_key.as_ref().map(Arc::clone)
235
    }
236

            
237
    pub async fn mark_stale(&self) {
238
        *self.disconnected_at.lock().await = Some(Instant::now());
239
    }
240

            
241
72
    pub async fn unmark_stale(&self) {
242
36
        *self.disconnected_at.lock().await = None;
243
    }
244

            
245
    pub async fn is_stale(&self) -> bool {
246
        self.disconnected_at
247
            .lock()
248
            .await
249
            .is_some_and(|t| t.elapsed() > SESSION_STALE_TIMEOUT)
250
    }
251
}
252

            
253
#[cfg(test)]
254
mod tests {
255
    use super::SessionType;
256
    use crate::tests::TestServiceSetup;
257

            
258
    /// Spawn a short-lived child process with a controlled environment, to
259
    /// exercise `SessionType::from_environ` against a real
260
    /// `/proc/<pid>/environ`.
261
    fn spawn_with_env(vars: &[(&str, &str)]) -> std::process::Child {
262
        let mut command = std::process::Command::new("sleep");
263
        command.arg("5").env_clear();
264
        for (key, value) in vars {
265
            command.env(key, value);
266
        }
267
        command.spawn().expect("failed to spawn test child process")
268
    }
269

            
270
    // Reads `/proc/<pid>/environ`
271
    #[cfg(target_os = "linux")]
272
    #[tokio::test]
273
    async fn from_environ_detects_wayland() {
274
        let mut child = spawn_with_env(&[("WAYLAND_DISPLAY", "wayland-test")]);
275

            
276
        let session_type = SessionType::from_environ(child.id()).await;
277

            
278
        let _ = child.kill();
279
        let _ = child.wait();
280

            
281
        assert_eq!(session_type, Some(SessionType::Wayland));
282
    }
283

            
284
    // Reads `/proc/<pid>/environ`
285
    #[cfg(target_os = "linux")]
286
    #[tokio::test]
287
    async fn from_environ_detects_x11() {
288
        let mut child = spawn_with_env(&[("DISPLAY", ":0")]);
289

            
290
        let session_type = SessionType::from_environ(child.id()).await;
291

            
292
        let _ = child.kill();
293
        let _ = child.wait();
294

            
295
        assert_eq!(session_type, Some(SessionType::X11));
296
    }
297

            
298
    #[tokio::test]
299
    async fn from_environ_none_without_display() {
300
        let mut child = spawn_with_env(&[]);
301

            
302
        let session_type = SessionType::from_environ(child.id()).await;
303

            
304
        let _ = child.kill();
305
        let _ = child.wait();
306

            
307
        assert_eq!(session_type, None);
308
    }
309

            
310
    #[test]
311
    fn from_display_vars_detects_wayland() {
312
        let vars = [b"WAYLAND_DISPLAY=wayland-test".as_slice(), b"FOO=bar"];
313

            
314
        assert_eq!(
315
            SessionType::from_display_vars(vars.into_iter()),
316
            Some(SessionType::Wayland)
317
        );
318
    }
319

            
320
    #[test]
321
    fn from_display_vars_detects_x11() {
322
        let vars = [b"DISPLAY=:0".as_slice(), b"FOO=bar"];
323

            
324
        assert_eq!(
325
            SessionType::from_display_vars(vars.into_iter()),
326
            Some(SessionType::X11)
327
        );
328
    }
329

            
330
    #[test]
331
    fn from_display_vars_prefers_wayland_over_x11() {
332
        let vars = [b"DISPLAY=:0".as_slice(), b"WAYLAND_DISPLAY=wayland-test"];
333

            
334
        assert_eq!(
335
            SessionType::from_display_vars(vars.into_iter()),
336
            Some(SessionType::Wayland)
337
        );
338
    }
339

            
340
    #[test]
341
    fn from_display_vars_ignores_empty_values() {
342
        let vars = [b"WAYLAND_DISPLAY=".as_slice(), b"DISPLAY="];
343

            
344
        assert_eq!(SessionType::from_display_vars(vars.into_iter()), None);
345
    }
346

            
347
    #[test]
348
    fn from_display_vars_none_without_display() {
349
        let vars = [b"FOO=bar".as_slice()];
350

            
351
        assert_eq!(SessionType::from_display_vars(vars.into_iter()), None);
352
    }
353

            
354
    #[tokio::test]
355
    async fn close() -> Result<(), Box<dyn std::error::Error>> {
356
        let setup = TestServiceSetup::plain_session(true).await?;
357
        let path = setup.session.inner().path().to_owned();
358

            
359
        // Verify session exists on the server
360
        let session_check = setup.server.session(&path).await;
361
        assert!(
362
            session_check.is_some(),
363
            "Session should exist on server before close"
364
        );
365

            
366
        // Close the session
367
        setup.session.close().await?;
368

            
369
        // Verify session no longer exists on the server
370
        let session_check_after = setup.server.session(&path).await;
371
        assert!(
372
            session_check_after.is_none(),
373
            "Session should not exist on server after close"
374
        );
375

            
376
        Ok(())
377
    }
378
}