Skip to main content

cairn_mod/cli/
error.rs

1//! Unified error taxonomy for the CLI handlers + the exit-code
2//! contract (criterion G / §F9's "specific exit codes per error
3//! class").
4//!
5//! Exit codes are a durable part of the CLI contract — scripts that
6//! branch on them will depend on the numbers below. Add new classes
7//! by appending new codes; renumbering is breaking.
8
9use std::net::SocketAddr;
10
11use thiserror::Error;
12
13use super::pds::PdsError;
14use super::session::SessionError;
15use crate::signing_key::KeyLoadError;
16
17/// Exit code taxonomy. Matches criterion G from the #16 plan.
18pub mod code {
19    /// Clean exit.
20    pub const SUCCESS: i32 = 0;
21    /// Usage / argument parsing. clap exits with 2 directly — this
22    /// constant exists so hand-written handlers agree.
23    pub const USAGE: i32 = 2;
24    /// Session file absent, permissions wrong, version mismatch.
25    pub const SESSION: i32 = 3;
26    /// Transport-level failure: unreachable host, TLS error, timeout.
27    pub const NETWORK: i32 = 4;
28    /// PDS rejected credentials OR Cairn returned 401.
29    pub const AUTH: i32 = 5;
30    /// Cairn returned 403.
31    pub const FORBIDDEN: i32 = 6;
32    /// Cairn returned some other 4xx.
33    pub const SERVER_4XX: i32 = 7;
34    /// Cairn returned 5xx.
35    pub const SERVER_5XX: i32 = 8;
36    /// Internal error (malformed response body, unexpected state).
37    pub const INTERNAL: i32 = 10;
38    /// `cairn serve` could not acquire the single-instance lease —
39    /// another Cairn process holds it with a fresh heartbeat (§F5).
40    /// Distinct from INTERNAL so systemd-restart-loop logic can
41    /// branch ("another instance is running, don't restart") vs.
42    /// genuine internal failure.
43    pub const LEASE_CONFLICT: i32 = 11;
44    /// Service record verify (§F1, #8): local `[labeler]` config
45    /// differs from the published `app.bsky.labeler.service` record
46    /// on the PDS. Operator action required: reconcile via
47    /// `cairn publish-service-record`.
48    pub const SERVICE_RECORD_DRIFT: i32 = 12;
49    /// Service record verify (§F1, #8): no record published yet.
50    /// Operator action required: run
51    /// `cairn publish-service-record` for the first time.
52    pub const SERVICE_RECORD_ABSENT: i32 = 13;
53    /// Service record verify (§F1, #8): PDS unreachable during
54    /// the verify fetch. Transient infra issue; orchestrators
55    /// should retry rather than treating as a config drift.
56    pub const SERVICE_RECORD_UNREACHABLE: i32 = 14;
57    /// `cairn audit verify` (#41) detected a hash-chain divergence:
58    /// the chain walked successfully up to row N-1, then row N's
59    /// recomputed hash did not match its stored row_hash. Distinct
60    /// exit code so monitoring/CI can branch on integrity-failure
61    /// (chain broken — operational alert) vs. generic CLI error.
62    pub const AUDIT_DIVERGENCE: i32 = 15;
63}
64
65/// Error sources the CLI dispatcher knows about. The human-readable
66/// `Display` output is what lands on stderr; [`CliError::exit_code`]
67/// maps variants onto the [`code`] table.
68#[derive(Debug, Error)]
69pub enum CliError {
70    /// Session-file failure (absent, permissions, ownership,
71    /// version mismatch, malformed JSON).
72    #[error("{0}")]
73    Session(#[from] SessionError),
74    /// PDS interaction failure (network, unauthorized, swap-race,
75    /// unexpected status, malformed response).
76    #[error("{0}")]
77    Pds(#[from] PdsError),
78    /// Authed command ran with no session file present.
79    #[error("not logged in; run `cairn login --cairn-server ... --pds ... --handle ...`")]
80    NotLoggedIn,
81    /// Transport-level failure contacting a Cairn server URL
82    /// (timeout, DNS, TLS, connection refused).
83    #[error("network error contacting {url}: {source}")]
84    Http {
85        /// URL that failed.
86        url: String,
87        /// Underlying reqwest error.
88        #[source]
89        source: reqwest::Error,
90    },
91    /// Cairn returned a non-2xx HTTP status.
92    #[error("Cairn at {url} returned {status}: {body}")]
93    CairnStatus {
94        /// URL that returned the error.
95        url: String,
96        /// HTTP status code.
97        status: u16,
98        /// Response body for operator-side diagnostics.
99        body: String,
100    },
101    /// Cairn returned a 2xx response but the body didn't parse as
102    /// the expected shape.
103    #[error("could not parse Cairn response from {url}: {source}")]
104    MalformedResponse {
105        /// URL whose response failed to parse.
106        url: String,
107        /// Underlying serde_json error.
108        #[source]
109        source: serde_json::Error,
110    },
111    /// Argument-shape failure — malformed CLI inputs, config
112    /// validation failures.
113    #[error("{0}")]
114    Config(String),
115    /// Signing key file couldn't be loaded (§5.1 invariant failure,
116    /// bad hex, wrong length, env-override attempt). Full cause lives
117    /// on the nested `KeyLoadError`; `Display` never includes the key
118    /// material because the key bytes are never decoded when an error
119    /// path runs, and `KeyLoadError::Display` only formats paths +
120    /// metadata.
121    #[error("signing key load failed: {0}")]
122    KeyLoad(#[from] KeyLoadError),
123    /// SQLite migrations failed at startup. String-only rather than
124    /// wrapping sqlx's error type to avoid leaking schema internals
125    /// into the error taxonomy.
126    #[error("migrations failed: {0}")]
127    MigrationFailed(String),
128    /// Another Cairn instance holds the single-instance lease (§F5).
129    /// Distinct variant so operators + systemd can branch on the
130    /// dedicated [`code::LEASE_CONFLICT`] exit code.
131    #[error("another Cairn instance holds the lease (instance_id={instance_id}, age={age_secs}s)")]
132    LeaseConflict {
133        /// Instance identifier held by the rival process.
134        instance_id: String,
135        /// Seconds since the rival last heartbeated.
136        age_secs: u64,
137    },
138    /// `TcpListener::bind` failed — port in use, permission denied,
139    /// malformed addr, etc.
140    #[error("could not bind {addr}: {source}")]
141    BindFailed {
142        /// Address we attempted to bind.
143        addr: SocketAddr,
144        /// Underlying I/O error.
145        #[source]
146        source: std::io::Error,
147    },
148    /// Anything in the startup sequence that doesn't fit the buckets
149    /// above (writer spawn, auth-context init). Rare at runtime;
150    /// surfaces as INTERNAL.
151    #[error("startup failure: {0}")]
152    Startup(String),
153    /// `cairn serve` startup verify (§F1, #8): the published
154    /// `app.bsky.labeler.service` record on the operator's PDS
155    /// differs from what the local `[labeler]` config block would
156    /// render. Reconcile via `cairn publish-service-record`.
157    #[error(
158        "service record verification failed: local [labeler] config differs from the published record at {pds_url}/{service_did}.\n\nDifferences:\n{summary}\n\nThe local config and the published record must agree before `cairn serve` will start. To reconcile, review the differences above, then on the operator's host run:\n\n    cairn publish-service-record --config <path>\n\nto update the PDS record."
159    )]
160    ServiceRecordDrift {
161        /// PDS URL the verify check fetched from.
162        pds_url: String,
163        /// Labeler service DID whose record was fetched.
164        service_did: String,
165        /// Per-field drift summary built by `serve::verify`. Not
166        /// raw JSON — a human-readable enumeration of the fields
167        /// that differ.
168        summary: String,
169    },
170    /// `cairn serve` startup verify (§F1, #8): no
171    /// `app.bsky.labeler.service` record exists on the operator's
172    /// PDS for the labeler's DID. The labeler hasn't published
173    /// its declaration yet.
174    #[error(
175        "service record verification failed: app.bsky.labeler.service record not found at {pds_url} for repo {service_did}.\n\nThis labeler has not yet published its declaration to the PDS. On the operator's host (where operator credentials are configured), run:\n\n    cairn publish-service-record --config <path>\n\nthen restart `cairn serve`."
176    )]
177    ServiceRecordAbsent {
178        /// PDS URL the verify check fetched from.
179        pds_url: String,
180        /// Labeler service DID whose record was searched.
181        service_did: String,
182    },
183    /// `cairn serve` startup verify (§F1, #8): could not reach
184    /// the PDS to perform the verify fetch. Transient infra
185    /// issue, distinct from drift / absent — orchestrators should
186    /// retry.
187    #[error(
188        "service record verification failed: could not reach PDS at {pds_url} to fetch app.bsky.labeler.service.\n\nUnderlying error: {cause}\n\nThis is likely a transient infrastructure issue (PDS down, network flaky, rate limit). Verify the PDS is reachable then retry. If the issue persists, check `operator.pds_url` in your config and confirm the labeler DID's home PDS."
189    )]
190    ServiceRecordUnreachable {
191        /// PDS URL the verify check tried to fetch from.
192        pds_url: String,
193        /// Underlying error message (transport, status, etc.).
194        cause: String,
195    },
196    /// `cairn audit verify` (#41) detected a hash-chain divergence.
197    /// The walk recomputed `row_id`'s row_hash from its content +
198    /// the running prev_hash and got `expected_hash`; the row's
199    /// stored `actual_hash` did not match. Distinct exit code
200    /// ([`code::AUDIT_DIVERGENCE`]) so monitoring/CI can branch on
201    /// integrity-failure vs. generic CLI error.
202    ///
203    /// The dispatcher prints the verify outcome (human or JSON) to
204    /// stdout *before* returning this error, so operators see the
205    /// structured report on stdout and the same message on stderr
206    /// alongside the non-zero exit. Stdout-only consumers
207    /// (`cairn audit verify --json`) get the JSON line; the stderr
208    /// echo is redundant for them but harmless.
209    #[error(
210        "audit chain divergence at {table}:{row_id}: expected {expected_hash}, found {actual_hash} ({attested_rows_before_divergence} row(s) verified before divergence)"
211    )]
212    AuditDivergence {
213        /// SQL-table name of the divergent row — `"audit_log"` or
214        /// `"pds_admin_audit"`. Added in #88 alongside the unified
215        /// chain walker so operators can correlate
216        /// `(table, row_id)` to a specific row across both tables
217        /// in v1.7+ deployments.
218        table: &'static str,
219        /// Primary key of the divergent row, scoped to the table
220        /// named by `table`.
221        row_id: i64,
222        /// Hex-encoded SHA-256 the chain says this row's row_hash
223        /// should be (recomputed from the running prev_hash + the
224        /// row's stored content).
225        expected_hash: String,
226        /// Hex-encoded SHA-256 actually stored in the row's
227        /// row_hash column.
228        actual_hash: String,
229        /// Number of rows whose hashes verified before this one —
230        /// the truncation point operators reconcile from. Counts
231        /// across both tables in the unified chain.
232        attested_rows_before_divergence: i64,
233    },
234}
235
236impl CliError {
237    /// Exit code per criterion G. Kept close to the Display output
238    /// so script authors can correlate message prefix with code.
239    pub fn exit_code(&self) -> i32 {
240        match self {
241            CliError::Session(SessionError::Io(_)) => code::SESSION,
242            CliError::Session(_) => code::SESSION,
243            CliError::NotLoggedIn => code::SESSION,
244            CliError::Pds(PdsError::Network { .. }) => code::NETWORK,
245            CliError::Pds(PdsError::Unauthorized { .. }) => code::AUTH,
246            CliError::Pds(PdsError::InvalidUrl { .. }) => code::USAGE,
247            CliError::Pds(PdsError::MalformedResponse { .. }) => code::INTERNAL,
248            CliError::Pds(PdsError::SwapRace { .. }) => code::INTERNAL,
249            CliError::Pds(PdsError::UnexpectedStatus { status, .. }) => match *status {
250                401 => code::AUTH,
251                403 => code::FORBIDDEN,
252                400..=499 => code::SERVER_4XX,
253                500..=599 => code::SERVER_5XX,
254                _ => code::INTERNAL,
255            },
256            CliError::Http { .. } => code::NETWORK,
257            CliError::CairnStatus { status, .. } => match *status {
258                401 => code::AUTH,
259                403 => code::FORBIDDEN,
260                400..=499 => code::SERVER_4XX,
261                500..=599 => code::SERVER_5XX,
262                _ => code::INTERNAL,
263            },
264            CliError::MalformedResponse { .. } => code::INTERNAL,
265            CliError::Config(_) => code::USAGE,
266            CliError::KeyLoad(_) => code::INTERNAL,
267            CliError::MigrationFailed(_) => code::INTERNAL,
268            CliError::LeaseConflict { .. } => code::LEASE_CONFLICT,
269            CliError::BindFailed { .. } => code::NETWORK,
270            CliError::Startup(_) => code::INTERNAL,
271            CliError::ServiceRecordDrift { .. } => code::SERVICE_RECORD_DRIFT,
272            CliError::ServiceRecordAbsent { .. } => code::SERVICE_RECORD_ABSENT,
273            CliError::ServiceRecordUnreachable { .. } => code::SERVICE_RECORD_UNREACHABLE,
274            CliError::AuditDivergence { .. } => code::AUDIT_DIVERGENCE,
275        }
276    }
277}