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
20
    async fn from_environ(pid: u32) -> Option<Self> {
59
17
        let environ = tokio::fs::read(format!("/proc/{pid}/environ")).await.ok()?;
60
20
        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
14
    fn from_display_vars<'a>(vars: impl Iterator<Item = &'a [u8]>) -> Option<Self> {
82
14
        let mut wayland = false;
83
15
        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
34
            } 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
24
        } 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
16
        tracing::info!("Closing session {} for {}.", self.path, self.sender);
192
12
        self.service.remove_session(&self.path).await;
193
24
        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
65
        let index = service.session_index();
210
        Self {
211
31
            path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/session/s{index}"))
212
                .unwrap(),
213
            aes_key,
214
            service,
215
            sender,
216
            peer_info,
217
60
            disconnected_at: Arc::new(Mutex::new(None)),
218
        }
219
    }
220

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

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

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

            
233
16
    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
66
    pub async fn unmark_stale(&self) {
242
33
        *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
    #[tokio::test]
271
    async fn from_environ_detects_wayland() {
272
        let mut child = spawn_with_env(&[("WAYLAND_DISPLAY", "wayland-test")]);
273

            
274
        let session_type = SessionType::from_environ(child.id()).await;
275

            
276
        let _ = child.kill();
277
        let _ = child.wait();
278

            
279
        assert_eq!(session_type, Some(SessionType::Wayland));
280
    }
281

            
282
    #[tokio::test]
283
    async fn from_environ_detects_x11() {
284
        let mut child = spawn_with_env(&[("DISPLAY", ":0")]);
285

            
286
        let session_type = SessionType::from_environ(child.id()).await;
287

            
288
        let _ = child.kill();
289
        let _ = child.wait();
290

            
291
        assert_eq!(session_type, Some(SessionType::X11));
292
    }
293

            
294
    #[tokio::test]
295
    async fn from_environ_none_without_display() {
296
        let mut child = spawn_with_env(&[]);
297

            
298
        let session_type = SessionType::from_environ(child.id()).await;
299

            
300
        let _ = child.kill();
301
        let _ = child.wait();
302

            
303
        assert_eq!(session_type, None);
304
    }
305

            
306
    #[test]
307
    fn from_display_vars_detects_wayland() {
308
        let vars = [b"WAYLAND_DISPLAY=wayland-test".as_slice(), b"FOO=bar"];
309

            
310
        assert_eq!(
311
            SessionType::from_display_vars(vars.into_iter()),
312
            Some(SessionType::Wayland)
313
        );
314
    }
315

            
316
    #[test]
317
    fn from_display_vars_detects_x11() {
318
        let vars = [b"DISPLAY=:0".as_slice(), b"FOO=bar"];
319

            
320
        assert_eq!(
321
            SessionType::from_display_vars(vars.into_iter()),
322
            Some(SessionType::X11)
323
        );
324
    }
325

            
326
    #[test]
327
    fn from_display_vars_prefers_wayland_over_x11() {
328
        let vars = [b"DISPLAY=:0".as_slice(), b"WAYLAND_DISPLAY=wayland-test"];
329

            
330
        assert_eq!(
331
            SessionType::from_display_vars(vars.into_iter()),
332
            Some(SessionType::Wayland)
333
        );
334
    }
335

            
336
    #[test]
337
    fn from_display_vars_ignores_empty_values() {
338
        let vars = [b"WAYLAND_DISPLAY=".as_slice(), b"DISPLAY="];
339

            
340
        assert_eq!(SessionType::from_display_vars(vars.into_iter()), None);
341
    }
342

            
343
    #[test]
344
    fn from_display_vars_none_without_display() {
345
        let vars = [b"FOO=bar".as_slice()];
346

            
347
        assert_eq!(SessionType::from_display_vars(vars.into_iter()), None);
348
    }
349

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

            
355
        // Verify session exists on the server
356
        let session_check = setup.server.session(&path).await;
357
        assert!(
358
            session_check.is_some(),
359
            "Session should exist on server before close"
360
        );
361

            
362
        // Close the session
363
        setup.session.close().await?;
364

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

            
372
        Ok(())
373
    }
374
}