Skip to main content

cairn_mod/cli/
session.rs

1//! CLI session file — §5.3.
2//!
3//! The session file caches the PDS session tokens the CLI obtains at
4//! `cairn login` time. Each subsequent authed command asks the PDS
5//! for a **fresh** service auth JWT (via `getServiceAuth`) using the
6//! access token stored here; the CLI never mints service auth JWTs
7//! itself. §5.3 is explicit that this file is a *moderator
8//! credential equivalent to the PDS app password*; the on-disk
9//! invariants below are designed to catch any drift from that
10//! security posture.
11//!
12//! On-disk invariants checked on every load:
13//! - Mode exactly `0o600`. Wider → reject.
14//! - File owned by the current effective UID. Mismatch → reject.
15//! - `version` field equals [`SESSION_VERSION`]. Mismatch → reject.
16//!
17//! Writes are atomic: tempfile in the same directory (created with
18//! mode `0o600` via `tempfile::NamedTempFile`, which uses
19//! `O_CREAT | O_EXCL` with the target permissions set at open-time on
20//! Unix), fsync, `rename(2)` into place. Same-directory POSIX rename
21//! is atomic — `cairn report`'s auto-refresh-then-persist flow
22//! relies on this.
23
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29use tempfile::NamedTempFile;
30use thiserror::Error;
31
32/// Current schema version written to disk. A load that finds a
33/// different value refuses to proceed rather than trying to migrate —
34/// a schema change warrants an explicit `cairn login` re-auth.
35pub const SESSION_VERSION: u32 = 1;
36
37/// Env-var override for the session file path. §5.3 names this as
38/// the scripted/CI escape hatch: pre-bake a session on a secure
39/// machine and point CI at it via secret management.
40pub const SESSION_FILE_ENV: &str = "CAIRN_SESSION_FILE";
41
42/// Relative path under the config dir where the session lands when
43/// no env override is set.
44const DEFAULT_RELATIVE_PATH: &str = "cairn/session.json";
45
46/// Cached PDS session + Cairn server identity. Everything needed to
47/// mint a service auth JWT at the PDS and send the result to Cairn,
48/// minus the moderator's actual signing key (which lives at the PDS).
49///
50/// Wire shape is serde-stable — a field addition requires bumping
51/// [`SESSION_VERSION`] to force re-login.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct SessionFile {
54    /// Schema version. Validated on load; see [`SESSION_VERSION`].
55    pub version: u32,
56    /// Base URL of the Cairn labeler this session targets (e.g.,
57    /// `https://labeler.example`). `cairn report --cairn-server`
58    /// overrides per-invocation but the session-stored value is the
59    /// default.
60    pub cairn_server_url: String,
61    /// Cairn's service DID, resolved at `cairn login` time from
62    /// `<cairn_server_url>/.well-known/did.json` (or supplied via
63    /// `--cairn-did` while #18's endpoint is pending). Used as `aud`
64    /// on every `getServiceAuth` call.
65    pub cairn_service_did: String,
66    /// Moderator's PDS base URL (e.g., `https://bsky.social`). Auth
67    /// source for every authed CLI call.
68    pub pds_url: String,
69    /// Authoritative moderator DID, taken from the `createSession`
70    /// response — never from user-supplied `--handle`. Becomes `iss`
71    /// on the PDS-minted service auth JWT.
72    pub moderator_did: String,
73    /// Moderator handle at login time, preserved for display only.
74    /// The DID above is the identity of record.
75    pub moderator_handle: String,
76    /// Short-lived PDS access token. Presented to PDS
77    /// `getServiceAuth` and refreshed on 401.
78    pub access_jwt: String,
79    /// Long-lived PDS refresh token. Presented to PDS
80    /// `refreshSession` when `access_jwt` expires.
81    pub refresh_jwt: String,
82}
83
84/// Error taxonomy for session-file operations. Surfaces enough
85/// context to the CLI dispatcher to choose the right exit code (see
86/// criterion G) without leaking the session contents.
87#[derive(Debug, Error)]
88pub enum SessionError {
89    /// Filesystem-level failure (read, write, permissions metadata).
90    #[error("io: {0}")]
91    Io(#[from] io::Error),
92    /// File mode is wider than `0o600` (§5.3 enforcement).
93    #[error("session file {path} has insecure permissions (mode {mode:o}); expected 600")]
94    InsecurePermissions {
95        /// Path to the session file.
96        path: PathBuf,
97        /// Actual mode bits (lower 9 bits).
98        mode: u32,
99    },
100    /// File exists but is owned by a UID other than the current
101    /// effective UID (§5.3 enforcement).
102    #[error("session file {path} is owned by another user")]
103    ForeignOwner {
104        /// Path to the session file.
105        path: PathBuf,
106    },
107    /// Session file's `version` field doesn't match the current
108    /// schema; operator must re-run `cairn login` to produce a
109    /// file the current binary understands.
110    #[error(
111        "session file {path} has unsupported version {found} (expected {expected}); re-run `cairn login`"
112    )]
113    UnsupportedVersion {
114        /// Path to the session file.
115        path: PathBuf,
116        /// Version found on disk.
117        found: u32,
118        /// Version this binary expects.
119        expected: u32,
120    },
121    /// JSON parse failure on the session file body.
122    #[error("session file {path} is malformed: {source}")]
123    Malformed {
124        /// Path to the session file.
125        path: PathBuf,
126        /// Underlying serde_json error.
127        #[source]
128        source: serde_json::Error,
129    },
130    /// `dirs::config_dir()` returned `None` and no
131    /// `CAIRN_SESSION_FILE` env override was set.
132    #[error("could not resolve a config directory (set {env} to override)", env = SESSION_FILE_ENV)]
133    NoConfigDir,
134    /// Non-Unix platform (§5.3 invariants require POSIX semantics).
135    #[error("cairn CLI on this platform is not supported in v1; use a POSIX filesystem and set {env}", env = SESSION_FILE_ENV)]
136    UnsupportedPlatform,
137}
138
139// The shared credential-file checker (§5.1 + §5.3) reports failures in
140// its own terms. Map into SessionError so callers keep getting the
141// session-specific variants they already branch on.
142impl From<crate::credential_file::CredentialFileError> for SessionError {
143    fn from(e: crate::credential_file::CredentialFileError) -> Self {
144        use crate::credential_file::CredentialFileError as C;
145        match e {
146            C::Io(io) => SessionError::Io(io),
147            C::InsecurePermissions { path, mode } => {
148                SessionError::InsecurePermissions { path, mode }
149            }
150            C::ForeignOwner { path } => SessionError::ForeignOwner { path },
151            C::UnsupportedPlatform => SessionError::UnsupportedPlatform,
152            // Session file loading never reject-on-env-override; the
153            // session module has no env-var override semantics for its
154            // credential bytes. If we ever get this variant it's a
155            // misuse; surface as Io to keep the taxonomy small.
156            C::EnvOverrideRejected { env } => SessionError::Io(io::Error::other(format!(
157                "unexpected env-override rejection for {env}"
158            ))),
159        }
160    }
161}
162
163/// Resolve the session path: `CAIRN_SESSION_FILE` env var first,
164/// otherwise `<config_dir>/cairn/session.json` per XDG.
165pub fn default_path() -> Result<PathBuf, SessionError> {
166    default_path_with_env(|k| std::env::var_os(k))
167}
168
169/// Injection seam: test doubles the env lookup without mutating
170/// process-global state (and without triggering `unsafe` under the
171/// crate-level `forbid(unsafe_code)` lint).
172fn default_path_with_env<F>(get: F) -> Result<PathBuf, SessionError>
173where
174    F: Fn(&str) -> Option<std::ffi::OsString>,
175{
176    if let Some(p) = get(SESSION_FILE_ENV) {
177        return Ok(PathBuf::from(p));
178    }
179    let base = dirs::config_dir().ok_or(SessionError::NoConfigDir)?;
180    Ok(base.join(DEFAULT_RELATIVE_PATH))
181}
182
183impl SessionFile {
184    /// Load a session from the given path.
185    ///
186    /// Returns `Ok(None)` when the file is absent — callers distinguish
187    /// "not logged in" from "session is broken" via this shape.
188    ///
189    /// All three on-disk invariants (mode, owner, version) are
190    /// checked before the JSON body is parsed.
191    pub fn load(path: &Path) -> Result<Option<Self>, SessionError> {
192        match fs::metadata(path) {
193            Ok(_) => {}
194            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
195            Err(e) => return Err(e.into()),
196        }
197
198        // §5.3 mode + owner invariants via the shared checker (§5.1 +
199        // §5.3 share the rule set — see src/credential_file.rs).
200        crate::credential_file::check_mode_and_owner(path)?;
201
202        let bytes = fs::read(path)?;
203        let session: SessionFile =
204            serde_json::from_slice(&bytes).map_err(|source| SessionError::Malformed {
205                path: path.to_path_buf(),
206                source,
207            })?;
208        if session.version != SESSION_VERSION {
209            return Err(SessionError::UnsupportedVersion {
210                path: path.to_path_buf(),
211                found: session.version,
212                expected: SESSION_VERSION,
213            });
214        }
215        Ok(Some(session))
216    }
217
218    /// Atomically write the session to `path` with mode `0o600`.
219    ///
220    /// Writes to a sibling tempfile (0600 at create-time), fsyncs,
221    /// then `rename`s. `cairn report`'s auto-refresh-then-persist
222    /// flow relies on this being atomic under concurrent readers.
223    pub fn save(&self, path: &Path) -> Result<(), SessionError> {
224        let parent = path.parent().ok_or_else(|| {
225            io::Error::new(io::ErrorKind::InvalidInput, "session path has no parent")
226        })?;
227        fs::create_dir_all(parent)?;
228
229        // Tighten directory mode at creation time; no-op if the
230        // directory already existed with a looser mode (operator's
231        // choice).
232        #[cfg(unix)]
233        {
234            use std::os::unix::fs::PermissionsExt;
235            let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
236        }
237
238        // NamedTempFile creates with 0600 on Unix by default
239        // (O_CREAT|O_EXCL with explicit mode at open-time). rename
240        // preserves that mode.
241        let mut tempfile = NamedTempFile::new_in(parent)?;
242        let body = serde_json::to_vec_pretty(self).expect("SessionFile serializes");
243        {
244            use std::io::Write as _;
245            tempfile.write_all(&body)?;
246            tempfile.as_file().sync_all()?;
247        }
248        tempfile.persist(path).map_err(|e| e.error)?;
249        Ok(())
250    }
251}
252
253/// Idempotent removal of the session file. `Ok(())` regardless of
254/// whether the file existed — `cairn logout` treats "already gone"
255/// as success.
256pub fn delete(path: &Path) -> Result<(), SessionError> {
257    match fs::remove_file(path) {
258        Ok(()) => Ok(()),
259        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
260        Err(e) => Err(e.into()),
261    }
262}
263
264// mode + owner checks moved to `crate::credential_file`. Unit tests
265// for the underlying predicates live there; the `wider_permissions_
266// rejected_on_load` integration test still covers the session-side
267// mapping (the `From<CredentialFileError> for SessionError` impl).
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::path::PathBuf;
273
274    #[test]
275    fn default_path_respects_env_override() {
276        let p = default_path_with_env(|k| {
277            assert_eq!(k, SESSION_FILE_ENV);
278            Some("/tmp/cairn-override.json".into())
279        })
280        .unwrap();
281        assert_eq!(p, PathBuf::from("/tmp/cairn-override.json"));
282    }
283
284    #[test]
285    fn default_path_without_env_falls_back_to_config_dir() {
286        let p = default_path_with_env(|_| None).unwrap();
287        assert!(
288            p.ends_with(DEFAULT_RELATIVE_PATH),
289            "fallback path must end with {DEFAULT_RELATIVE_PATH}, got {p:?}"
290        );
291    }
292}