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}