arf-console 0.5.0

A cross-platform R console written in Rust
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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! Session metadata management for IPC discovery.
//!
//! Each arf process with IPC enabled writes a session file to
//! `~/.cache/arf/sessions/<pid>.json` so that clients can discover
//! running sessions and their socket paths.
//!
//! Set `ARF_IPC_SESSIONS_DIR` to override the sessions directory for both
//! writers and readers. This is useful for hermetic tests and explicit
//! multi-instance isolation.
//!
//! # `ARF_IPC_SESSIONS_DIR` usage notes
//!
//! - **Use an absolute path.** Relative paths are resolved against each
//!   process's current working directory; if the writer and reader start
//!   from different directories they will silently look in different
//!   locations.
//! - **Use a user-private directory.** The default (`~/.cache/arf/sessions`)
//!   is created with mode 0700. When overriding to an already-existing
//!   directory (e.g. `/tmp/my-dir`), ensure it is not world-accessible;
//!   although session files are created with mode 0600, their filenames
//!   (which reveal PIDs) would be visible to other users.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

const ARF_IPC_SESSIONS_DIR: &str = "ARF_IPC_SESSIONS_DIR";

/// Session metadata written to disk for client discovery.
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionInfo {
    pub pid: u32,
    pub socket_path: String,
    pub r_version: Option<String>,
    /// R installation path reported by the running R at startup, or `None` if R is unavailable.
    /// The field is absent in session files written by older arf versions.
    #[serde(default)]
    pub r_home: Option<String>,
    pub cwd: String,
    pub started_at: String,
    /// Whether this session is headless or interactive.
    pub session_type: SessionType,
    /// Log file path, or `None` if no log file is configured.
    #[serde(default)]
    pub log_file: Option<String>,
    /// History session ID (nanosecond timestamp), or `None` when history
    /// initialization is unavailable.
    #[serde(default)]
    pub history_session_id: Option<i64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionType {
    Headless,
    Interactive,
}

/// Return the directory where session files are stored.
pub fn sessions_dir() -> Option<PathBuf> {
    if let Some(override_dir) = std::env::var_os(ARF_IPC_SESSIONS_DIR) {
        let path = PathBuf::from(override_dir);
        if !path.as_os_str().is_empty() {
            return Some(path);
        }
    }
    dirs::cache_dir().map(|d| d.join("arf").join("sessions"))
}

/// Write session metadata to disk.
///
/// On Unix, the sessions directory is created with mode 0700 and the session
/// file with mode 0600 so that other users cannot discover or connect to the
/// IPC socket.
pub fn write_session(info: &SessionInfo) -> std::io::Result<()> {
    let dir = sessions_dir().ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "cache directory not found")
    })?;
    // Create directory with mode 0700 atomically on Unix to avoid TOCTOU
    // race between create_dir_all and set_permissions.
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt;
        std::fs::DirBuilder::new()
            .recursive(true)
            .mode(0o700)
            .create(&dir)?;
    }
    #[cfg(not(unix))]
    {
        std::fs::create_dir_all(&dir)?;
    }

    let path = dir.join(format!("{}.json", info.pid));
    let json = serde_json::to_string_pretty(info).map_err(std::io::Error::other)?;

    // On Unix, create the file with restricted permissions atomically
    #[cfg(unix)]
    {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(&path)?;
        file.write_all(json.as_bytes())?;
    }
    #[cfg(not(unix))]
    {
        std::fs::write(&path, &json)?;
    }

    log::info!("Session file written: {}", path.display());
    Ok(())
}

/// Clear the `history_session_id` field in the on-disk session file for this process.
///
/// Reads the current session file, sets `history_session_id` to `null`, and rewrites it.
/// Errors are logged but not propagated since this is a best-effort cleanup.
pub fn clear_session_history_id(pid: u32) {
    let Some(dir) = sessions_dir() else { return };
    let path = dir.join(format!("{pid}.json"));
    let contents = match std::fs::read_to_string(&path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(e) => {
            log::debug!("Could not read session file {}: {}", path.display(), e);
            return;
        }
    };
    let mut info: SessionInfo = match serde_json::from_str(&contents) {
        Ok(i) => i,
        Err(e) => {
            log::debug!("Could not parse session file {}: {}", path.display(), e);
            return;
        }
    };
    info.history_session_id = None;
    // Ensure we rewrite the same file even if the stored PID differs
    // (e.g. due to file corruption or tampering).
    info.pid = pid;
    if let Err(e) = write_session(&info) {
        log::debug!("Could not rewrite session file {}: {}", path.display(), e);
    }
}

/// Remove session metadata on shutdown.
pub fn remove_session(pid: u32) {
    if let Some(dir) = sessions_dir() {
        let path = dir.join(format!("{pid}.json"));
        if let Err(e) = std::fs::remove_file(&path) {
            log::debug!("Could not remove session file {}: {}", path.display(), e);
        }
    }
}

/// List all session files, filtering out stale ones (where the process no longer exists).
pub fn list_sessions() -> Vec<SessionInfo> {
    let dir = match sessions_dir() {
        Some(d) if d.exists() => d,
        _ => return Vec::new(),
    };

    let mut sessions = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(_) => return Vec::new(),
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if !path.extension().is_some_and(|ext| ext == "json") {
            continue;
        }

        let Ok(contents) = std::fs::read_to_string(&path) else {
            continue;
        };
        match serde_json::from_str::<SessionInfo>(&contents) {
            Ok(info) if is_process_alive(info.pid) => sessions.push(info),
            Ok(_) => {
                let _ = std::fs::remove_file(&path);
            }
            Err(_) => cleanup_invalid_session_file(&path, &contents),
        }
    }

    sessions
}

/// Remove an invalid session file only when its JSON identifies the same dead
/// PID as its filename. Invalid files for live processes remain hidden but are
/// left untouched, as are files whose contents and names disagree.
fn cleanup_invalid_session_file(path: &Path, contents: &str) {
    let Ok(json) = serde_json::from_str::<serde_json::Value>(contents) else {
        return;
    };
    let Some(pid) = json
        .get("pid")
        .and_then(serde_json::Value::as_u64)
        .and_then(|pid| u32::try_from(pid).ok())
    else {
        return;
    };
    let Some(filename_pid) = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .and_then(|stem| stem.parse::<u32>().ok())
    else {
        return;
    };
    if pid == filename_pid && !is_process_alive(pid) {
        let _ = std::fs::remove_file(path);
    }
}

/// Find a session by PID, or return the only running session if PID is not specified.
pub fn find_session(pid: Option<u32>) -> Option<SessionInfo> {
    let sessions = list_sessions();
    match pid {
        Some(target_pid) => sessions.into_iter().find(|s| s.pid == target_pid),
        None => {
            if sessions.len() == 1 {
                sessions.into_iter().next()
            } else {
                None
            }
        }
    }
}

/// Check if a process with the given PID is still running.
fn is_process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // On Unix, sending signal 0 checks process existence without actually signaling.
        // Returns 0 on success, -1 on error. EPERM means the process exists but we
        // lack permission to signal it — still alive.
        let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
        if ret == 0 {
            return true;
        }
        // EPERM: process exists but not owned by us
        std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
    }
    #[cfg(windows)]
    {
        use std::process::Command;
        Command::new("tasklist")
            .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
            .output()
            .is_ok_and(|o| {
                let out = String::from_utf8_lossy(&o.stdout);
                // CSV format: "name","pid",...  — match exact PID field
                out.lines().any(|line| {
                    line.split(',')
                        .nth(1)
                        .is_some_and(|f| f.trim_matches('"') == pid.to_string())
                })
            })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn session_info(session_type: SessionType) -> SessionInfo {
        SessionInfo {
            pid: 12345,
            socket_path: "/tmp/arf.sock".to_string(),
            r_version: Some("4.4.1".to_string()),
            r_home: None,
            cwd: "/tmp".to_string(),
            started_at: "2026-01-01T00:00:00+00:00".to_string(),
            session_type,
            log_file: None,
            history_session_id: Some(42),
        }
    }

    #[test]
    fn session_type_serializes_and_round_trips() {
        let mut info = session_info(SessionType::Headless);
        info.r_home = Some("/opt/R/4.4.1/lib/R".to_string());
        let json = serde_json::to_value(&info).unwrap();

        assert_eq!(json["session_type"], "headless");
        assert_eq!(json["r_home"], "/opt/R/4.4.1/lib/R");
        insta::assert_snapshot!(serde_json::to_string_pretty(&json).unwrap(), @r###"
{
  "cwd": "/tmp",
  "history_session_id": 42,
  "log_file": null,
  "pid": 12345,
  "r_home": "/opt/R/4.4.1/lib/R",
  "r_version": "4.4.1",
  "session_type": "headless",
  "socket_path": "/tmp/arf.sock",
  "started_at": "2026-01-01T00:00:00+00:00"
}"###);

        let restored: SessionInfo = serde_json::from_value(json).unwrap();
        assert_eq!(restored.session_type, SessionType::Headless);
        assert_eq!(restored.r_home.as_deref(), Some("/opt/R/4.4.1/lib/R"));
    }

    #[test]
    fn session_type_is_required_in_session_files() {
        let json = serde_json::json!({
            "pid": 12345,
            "socket_path": "/tmp/arf.sock",
            "r_version": "4.4.1",
            "cwd": "/tmp",
            "started_at": "2026-01-01T00:00:00+00:00",
        });

        assert!(serde_json::from_value::<SessionInfo>(json).is_err());
    }

    #[test]
    fn null_session_type_is_rejected() {
        let json = serde_json::json!({
            "pid": 12345,
            "socket_path": "/tmp/arf.sock",
            "r_version": "4.4.1",
            "cwd": "/tmp",
            "started_at": "2026-01-01T00:00:00+00:00",
            "session_type": null,
        });

        assert!(serde_json::from_value::<SessionInfo>(json).is_err());
    }

    #[test]
    fn list_sessions_removes_dead_legacy_session_file() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut guard = crate::test_utils::lock_env();
        guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());

        let pid = std::process::id().saturating_add(1_000_000);
        let path = temp_dir.path().join(format!("{pid}.json"));
        let json = serde_json::json!({
            "pid": pid,
            "socket_path": "/tmp/arf.sock",
            "r_version": null,
            "cwd": "/tmp",
            "started_at": "1970-01-01T00:00:00Z",
            "r_home": null,
            "log_file": null,
            "history_session_id": null,
        });
        std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();

        assert!(list_sessions().is_empty());
        assert!(!path.exists());
    }

    #[test]
    fn list_sessions_hides_but_keeps_live_legacy_session_file() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut guard = crate::test_utils::lock_env();
        guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());

        let pid = std::process::id();
        let path = temp_dir.path().join(format!("{pid}.json"));
        let json = serde_json::json!({
            "pid": pid,
            "socket_path": "/tmp/arf.sock",
            "r_version": null,
            "cwd": "/tmp",
            "started_at": "1970-01-01T00:00:00Z",
            "r_home": null,
            "log_file": null,
            "history_session_id": null,
        });
        std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();

        assert!(list_sessions().is_empty());
        assert!(path.exists());
    }

    #[test]
    fn list_sessions_keeps_legacy_file_when_pid_does_not_match_filename() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut guard = crate::test_utils::lock_env();
        guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());

        let pid = std::process::id();
        let filename_pid = pid.saturating_add(1_000_000);
        let path = temp_dir.path().join(format!("{filename_pid}.json"));
        let json = serde_json::json!({
            "pid": pid,
            "socket_path": "/tmp/arf.sock",
            "r_version": null,
            "cwd": "/tmp",
            "started_at": "1970-01-01T00:00:00Z",
            "r_home": null,
            "log_file": null,
            "history_session_id": null,
        });
        std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();

        assert!(list_sessions().is_empty());
        assert!(path.exists());
    }

    #[test]
    fn legacy_session_without_r_home_deserializes_as_unknown() {
        let json = serde_json::json!({
            "pid": 12345,
            "socket_path": "/tmp/arf.sock",
            "r_version": "4.4.1",
            "cwd": "/tmp",
            "started_at": "2026-01-01T00:00:00+00:00",
            "session_type": "interactive",
        });

        let info: SessionInfo = serde_json::from_value(json).unwrap();
        assert_eq!(info.r_home, None);

        let listed = serde_json::to_value(&info).unwrap();
        assert!(listed.as_object().unwrap().contains_key("r_home"));
        assert!(listed["r_home"].is_null());
    }

    #[test]
    fn clear_session_history_id_preserves_session_type() {
        let temp_dir = tempfile::tempdir().unwrap();
        // clear_session_history_id reads ARF_IPC_SESSIONS_DIR through sessions_dir.
        let mut guard = crate::test_utils::lock_env();
        guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());

        let pid = std::process::id();
        let info = SessionInfo {
            pid,
            ..session_info(SessionType::Interactive)
        };
        write_session(&info).unwrap();

        clear_session_history_id(pid);

        let contents =
            std::fs::read_to_string(temp_dir.path().join(format!("{pid}.json"))).unwrap();
        let cleared: SessionInfo = serde_json::from_str(&contents).unwrap();
        assert_eq!(cleared.history_session_id, None);
        assert_eq!(cleared.session_type, SessionType::Interactive);
    }
}