Skip to main content

steam_client/
persistence.rs

1use std::{collections::HashMap, fs, path::PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::LogOnDetails;
6
7#[derive(Serialize, Deserialize, Clone)]
8struct SavedSession {
9    account_name: String,
10    refresh_token: String,
11    steam_id: u64,
12}
13
14impl std::fmt::Debug for SavedSession {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        f.debug_struct("SavedSession")
17            .field("account_name", &self.account_name)
18            .field("steam_id", &self.steam_id)
19            // Sensitive field — redacted
20            .field("refresh_token", &"<redacted>")
21            .finish()
22    }
23}
24
25pub struct TokenStore {
26    path: PathBuf,
27}
28
29impl TokenStore {
30    pub fn new(filename: &str) -> Self {
31        Self { path: PathBuf::from(filename) }
32    }
33
34    /// Load all sessions from disk
35    fn load_all(&self) -> HashMap<String, SavedSession> {
36        if !self.path.exists() {
37            return HashMap::new();
38        }
39        let data = match fs::read_to_string(&self.path) {
40            Ok(d) => d,
41            Err(_) => return HashMap::new(),
42        };
43        serde_json::from_str(&data).unwrap_or_default()
44    }
45
46    /// Attempt to load a session for a specific user.
47    /// If no user specified, returns the first one found (single-user mode).
48    pub fn load(&self, account_name: Option<&str>) -> Option<LogOnDetails> {
49        let sessions = self.load_all();
50
51        let session = if let Some(name) = account_name {
52            sessions.get(name)?
53        } else {
54            // "Default" behavior: just grab the first one
55            sessions.values().next()?
56        };
57
58        Some(LogOnDetails {
59            account_name: Some(session.account_name.clone()),
60            refresh_token: Some(session.refresh_token.clone()),
61            // steam_id: Some(session.steam_id), // Useful for pre-filling
62            ..Default::default()
63        })
64    }
65
66    /// Save a token (updates existing entry or adds new one).
67    ///
68    /// Writes atomically: serialises to a `.tmp` sibling, restricts file
69    /// permissions on Unix (0o600), then renames over the destination so a
70    /// partial write never leaves a truncated session file on disk.
71    pub fn save(&self, account_name: String, token: String, steam_id: u64) -> std::io::Result<()> {
72        let mut sessions = self.load_all();
73
74        sessions.insert(
75            account_name.clone(),
76            SavedSession { account_name: account_name.clone(), refresh_token: token, steam_id },
77        );
78
79        let data = serde_json::to_string_pretty(&sessions)?;
80
81        // Build the temp path alongside the destination.
82        let mut tmp_path = self.path.clone();
83        let mut tmp_name = self.path
84            .file_name()
85            .map(|n| n.to_os_string())
86            .unwrap_or_default();
87        tmp_name.push(".tmp");
88        tmp_path.set_file_name(tmp_name);
89
90        // Write to the temp file first.
91        fs::write(&tmp_path, &data)?;
92
93        // Restrict permissions before the file is moved into place.
94        #[cfg(unix)]
95        {
96            use std::os::unix::fs::PermissionsExt;
97            fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?;
98        }
99
100        // On Windows there is no direct stdlib equivalent to chmod 600.
101        // ACL restriction (e.g. via SetNamedSecurityInfo) requires windows-sys
102        // or similar, which is not a dependency of this crate.  The atomic
103        // rename below still ensures no plaintext lingers on a partial write;
104        // callers that need tighter ACLs should restrict the parent directory.
105        #[cfg(windows)]
106        {
107            tracing::debug!(
108                path = %self.path.display(),
109                "TokenStore::save: ACL restriction on Windows is left to the caller; \
110                 rename is still atomic to prevent partial-write exposure"
111            );
112        }
113
114        // Atomic rename: destination either keeps old content or gets new content,
115        // never a partially-written file.
116        fs::rename(&tmp_path, &self.path)
117    }
118}