Skip to main content

cairn_mod/cli/
operator_session.rs

1//! Operator PDS session file (§F1).
2//!
3//! The operator is the DID that OWNS the labeler's PDS account —
4//! distinct from moderators (§5.2, §5.3) who authenticate to Cairn.
5//! Both use §5.3 file-permission invariants via the shared
6//! [`crate::credential_file`] helper, but the files are separate:
7//! different credentials, different paths, different lifetimes.
8//!
9//! Written by `cairn operator-login`, read by
10//! `cairn publish-service-record`. Path lives in `config.operator.
11//! session_path` — no XDG default because operator ops are deployment-
12//! scoped (systemd service, release playbook) rather than user-scoped.
13
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use tempfile::NamedTempFile;
20use thiserror::Error;
21
22/// Current schema version. A load finding a different value refuses
23/// to proceed and asks the operator to re-run `cairn operator-login`.
24pub const OPERATOR_SESSION_VERSION: u32 = 1;
25
26/// Wire shape of the operator session file. Deliberately narrower
27/// than the moderator `SessionFile` from #16 — operator ops talk to
28/// the PDS, not to Cairn, so cairn_server_url / cairn_service_did
29/// would be inapplicable and misleading.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct OperatorSession {
32    /// Schema version. Validated on load; mismatch requires a
33    /// fresh `cairn operator-login`.
34    pub version: u32,
35    /// The PDS the operator authenticated against.
36    pub pds_url: String,
37    /// Authoritative operator DID from `createSession`, not whatever
38    /// handle the operator typed.
39    pub operator_did: String,
40    /// Handle at login time. Display only; the DID is identity.
41    pub operator_handle: String,
42    /// Short-lived PDS access token. Refreshed on 401 via
43    /// `refreshSession`.
44    pub access_jwt: String,
45    /// Long-lived PDS refresh token.
46    pub refresh_jwt: String,
47}
48
49/// Error surface for operator-session load/save.
50#[derive(Debug, Error)]
51pub enum OperatorSessionError {
52    /// Filesystem I/O failure.
53    #[error("io: {0}")]
54    Io(#[from] io::Error),
55    /// Session file JSON didn't parse.
56    #[error("operator session file {path} is malformed: {source}")]
57    Malformed {
58        /// Path to the session file.
59        path: PathBuf,
60        /// Underlying serde_json error.
61        #[source]
62        source: serde_json::Error,
63    },
64    /// Session file version mismatch; operator must re-login.
65    #[error(
66        "operator session file {path} has unsupported version {found} (expected {expected}); re-run `cairn operator-login`"
67    )]
68    UnsupportedVersion {
69        /// Path to the session file.
70        path: PathBuf,
71        /// Version found on disk.
72        found: u32,
73        /// Version this binary expects.
74        expected: u32,
75    },
76    /// Credential-file invariant failure (mode / owner / platform).
77    #[error("{0}")]
78    CredentialFile(#[from] crate::credential_file::CredentialFileError),
79}
80
81impl OperatorSession {
82    /// Load the session at `path`. Returns `Ok(None)` if absent so
83    /// `publish-service-record` can distinguish "not logged in"
84    /// from "session broken." All §5.3 file invariants (mode, owner,
85    /// platform) checked before parsing.
86    pub fn load(path: &Path) -> Result<Option<Self>, OperatorSessionError> {
87        match fs::metadata(path) {
88            Ok(_) => {}
89            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
90            Err(e) => return Err(e.into()),
91        }
92        crate::credential_file::check_mode_and_owner(path)?;
93
94        let bytes = fs::read(path)?;
95        let session: OperatorSession =
96            serde_json::from_slice(&bytes).map_err(|source| OperatorSessionError::Malformed {
97                path: path.to_path_buf(),
98                source,
99            })?;
100        if session.version != OPERATOR_SESSION_VERSION {
101            return Err(OperatorSessionError::UnsupportedVersion {
102                path: path.to_path_buf(),
103                found: session.version,
104                expected: OPERATOR_SESSION_VERSION,
105            });
106        }
107        Ok(Some(session))
108    }
109
110    /// Atomic write identical to the moderator session-file flow:
111    /// sibling tempfile at 0600 (O_CREAT | O_EXCL open mode),
112    /// fsync, `rename(2)`. Same POSIX guarantee the auto-refresh
113    /// path relies on.
114    pub fn save(&self, path: &Path) -> Result<(), OperatorSessionError> {
115        let parent = path.parent().ok_or_else(|| {
116            io::Error::new(io::ErrorKind::InvalidInput, "session path has no parent")
117        })?;
118        fs::create_dir_all(parent)?;
119        #[cfg(unix)]
120        {
121            use std::os::unix::fs::PermissionsExt;
122            let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
123        }
124        let mut tmp = NamedTempFile::new_in(parent)?;
125        let body = serde_json::to_vec_pretty(self).expect("OperatorSession serializes");
126        {
127            use std::io::Write as _;
128            tmp.write_all(&body)?;
129            tmp.as_file().sync_all()?;
130        }
131        tmp.persist(path).map_err(|e| e.error)?;
132        Ok(())
133    }
134}
135
136/// Idempotent file removal — same contract as
137/// [`crate::cli::session::delete`].
138pub fn delete(path: &Path) -> Result<(), OperatorSessionError> {
139    match fs::remove_file(path) {
140        Ok(()) => Ok(()),
141        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
142        Err(e) => Err(e.into()),
143    }
144}