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}