Skip to main content

kopiur_kopia/
error.rs

1//! Error types for kopia subprocess invocation and JSON parsing.
2//!
3//! A terminal [`KopiaError`] must carry enough structured detail for the mover
4//! to build a `status.failure` block (ADR §4.10): exit code, the last lines of
5//! stderr, and a best-effort *error class* derived from kopia's stderr so the
6//! controller can decide whether a retry is worthwhile.
7
8use std::fmt;
9
10use crate::humanize::{exit_code_desc, humanize_tail};
11
12/// How many trailing lines of stderr we retain on a failed invocation. Kopia
13/// can print a lot of progress to stderr; the tail is where the actual error
14/// message lands.
15pub const STDERR_TAIL_LINES: usize = 20;
16
17/// A best-effort classification of a kopia failure, derived by inspecting the
18/// captured stderr. This is intentionally coarse — it exists to drive the
19/// "should we retry?" decision in the mover, not to be exhaustive. Unknown
20/// failures map to [`KopiaErrorClass::Unknown`] and are treated as
21/// non-retryable by default.
22///
23/// Classification reads kopia's stderr; the class then drives the retry hint and
24/// round-trips through its stable label:
25///
26/// ```
27/// use kopiur_kopia::KopiaErrorClass;
28///
29/// // A backend down / unreachable error is transient → worth a retry.
30/// let class = KopiaErrorClass::classify("ERROR error connecting to repository: dial tcp");
31/// assert_eq!(class, KopiaErrorClass::RepositoryUnavailable);
32/// assert!(class.is_retryable());
33///
34/// // A wrong repository password is not retryable without a config change.
35/// let auth = KopiaErrorClass::classify("invalid repository password");
36/// assert_eq!(auth, KopiaErrorClass::AuthFailure);
37/// assert!(!auth.is_retryable());
38///
39/// // The stable label round-trips through from_label/as_str.
40/// assert_eq!(class.as_str(), "RepositoryUnavailable");
41/// assert_eq!(KopiaErrorClass::from_label("RepositoryUnavailable"), class);
42/// // An unrecognized label degrades to Unknown.
43/// assert_eq!(KopiaErrorClass::from_label("bogus"), KopiaErrorClass::Unknown);
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum KopiaErrorClass {
47    /// Repository could not be reached / opened (network, backend down,
48    /// bad endpoint). Typically transient → retry.
49    RepositoryUnavailable,
50    /// Authentication / password / credential failure (wrong repository
51    /// password). Not retryable without a config change.
52    AuthFailure,
53    /// The storage backend **denied access** to the bucket/container/object
54    /// (e.g. S3/B2/GCS "Access Denied", HTTP 403). The credentials usually
55    /// authenticate fine but lack permission — or the bucket/path doesn't exist
56    /// and the backend masks that as access-denied (RustFS/S3 do this). Not
57    /// retryable without a credentials/permission/bucket fix.
58    AccessDenied,
59    /// The repository **path is not writable by this process** — e.g. a
60    /// filesystem repo whose PVC/NFS export is not writable by the operator's
61    /// UID ("permission denied" / EACCES when connecting or creating). Not
62    /// retryable without fixing ownership/mode.
63    PermissionDenied,
64    /// The requested source path / snapshot / target was not found.
65    NotFound,
66    /// A repository lock is held by another writer. Often transient → retry.
67    Locked,
68    /// Source filesystem error during upload (I/O, prepare failure).
69    SourceError,
70    /// Anything we could not classify.
71    Unknown,
72}
73
74impl KopiaErrorClass {
75    /// Stable string form for status fields / metrics labels.
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            KopiaErrorClass::RepositoryUnavailable => "RepositoryUnavailable",
79            KopiaErrorClass::AuthFailure => "AuthFailure",
80            KopiaErrorClass::AccessDenied => "AccessDenied",
81            KopiaErrorClass::PermissionDenied => "PermissionDenied",
82            KopiaErrorClass::NotFound => "NotFound",
83            KopiaErrorClass::Locked => "Locked",
84            KopiaErrorClass::SourceError => "SourceError",
85            KopiaErrorClass::Unknown => "Unknown",
86        }
87    }
88
89    /// Inverse of [`as_str`](Self::as_str): reconstruct the class from its stable
90    /// label. Used when only the persisted string is available (the controller
91    /// reads `result.failure.kopiaErrorClass` from a bootstrap Job's ConfigMap).
92    /// An unrecognized label maps to [`KopiaErrorClass::Unknown`].
93    pub fn from_label(s: &str) -> KopiaErrorClass {
94        match s {
95            "RepositoryUnavailable" => KopiaErrorClass::RepositoryUnavailable,
96            "AuthFailure" => KopiaErrorClass::AuthFailure,
97            "AccessDenied" => KopiaErrorClass::AccessDenied,
98            "PermissionDenied" => KopiaErrorClass::PermissionDenied,
99            "NotFound" => KopiaErrorClass::NotFound,
100            "Locked" => KopiaErrorClass::Locked,
101            "SourceError" => KopiaErrorClass::SourceError,
102            _ => KopiaErrorClass::Unknown,
103        }
104    }
105
106    /// A **stable**, volatile-free one-line summary of what this class means and
107    /// how to fix it, suitable for a status *condition message*.
108    ///
109    /// Unlike `KopiaError::to_string` (which embeds the kopia stderr tail — and
110    /// thus a per-attempt-random temp filename like `.shards.tmp.<hex>`), this is
111    /// byte-identical across repeated failures of the same class. The controller
112    /// uses it for the persisted condition so that re-writing an unchanged Failed
113    /// status is a true no-op (no resourceVersion bump → no self-triggered
114    /// reconcile). The full, volatile detail still goes to the Warning Event.
115    pub fn summary(&self) -> &'static str {
116        match self {
117            KopiaErrorClass::RepositoryUnavailable => {
118                "repository backend is unreachable; check the endpoint/network and retry"
119            }
120            KopiaErrorClass::AuthFailure => {
121                "repository password was rejected; check the encryption password Secret \
122                 (the KOPIA_PASSWORD key)"
123            }
124            KopiaErrorClass::AccessDenied => {
125                "the storage backend denied access; check the credentials Secret and that the \
126                 bucket/container/path exists"
127            }
128            KopiaErrorClass::PermissionDenied => {
129                "repository path is not writable by the operator's UID; fix ownership/mode on the \
130                 backing PVC or NFS export"
131            }
132            KopiaErrorClass::NotFound => {
133                "the repository path, snapshot, or target was not found; check the backend \
134                 path/prefix"
135            }
136            KopiaErrorClass::Locked => {
137                "a repository lock is held by another writer; it usually clears on retry"
138            }
139            KopiaErrorClass::SourceError => {
140                "could not read the source volume during upload; check the volume and the mover \
141                 Job logs"
142            }
143            KopiaErrorClass::Unknown => {
144                "unknown repository error; see the mover Job logs and status.failure"
145            }
146        }
147    }
148
149    /// Whether re-running the same operation later might succeed without any
150    /// configuration change. This is the operator's default retry hint; the
151    /// caller may override it with policy.
152    pub fn is_retryable(&self) -> bool {
153        matches!(
154            self,
155            KopiaErrorClass::RepositoryUnavailable
156                | KopiaErrorClass::Locked
157                | KopiaErrorClass::SourceError
158        )
159    }
160
161    /// Best-effort classification from captured stderr text. Matches against
162    /// substrings kopia is observed to emit (kopia 0.23). Order matters: more
163    /// specific checks come first.
164    pub fn classify(stderr: &str) -> KopiaErrorClass {
165        let s = stderr.to_ascii_lowercase();
166        if s.contains("invalid repository password")
167            || s.contains("incorrect password")
168            || s.contains("unable to derive")
169        {
170            KopiaErrorClass::AuthFailure
171        } else if s.contains("access denied")
172            || s.contains("accessdenied")
173            || s.contains("forbidden")
174            || s.contains("not authorized")
175        {
176            // Backend authorization (e.g. S3 "Access Denied"). Checked before the
177            // generic permission/not-found arms because the backend phrasing is
178            // specific and the fix (creds/bucket) is distinct.
179            KopiaErrorClass::AccessDenied
180        } else if s.contains("permission denied")
181            || s.contains("operation not permitted")
182            || s.contains("eacces")
183        {
184            // Local repo path not writable by our UID. Checked before SourceError
185            // (which used to absorb "permission denied" and wrongly mark it
186            // retryable) and before NotFound.
187            KopiaErrorClass::PermissionDenied
188        } else if s.contains("repository is locked")
189            || s.contains("another process")
190            || s.contains("lock")
191        {
192            KopiaErrorClass::Locked
193        } else if s.contains("no such file or directory")
194            || s.contains("not found")
195            || s.contains("does not exist")
196            || s.contains("unable to find snapshot")
197            // kopia's `repo.ErrRepositoryNotInitialized` ("repository not initialized
198            // in the provided storage") — an *empty* backend with no kopia repo at the
199            // prefix. The CLI wraps it as `error connecting to repository: repository
200            // not initialized ...`, so it MUST be matched here, ahead of the
201            // RepositoryUnavailable arm, or an uninitialized repo is misread as an
202            // unreachable backend. Classifying it `NotFound` is what lets the mover
203            // surface the actionable `RepositoryNotInitialized` outcome (set
204            // `spec.create.enabled: true`) instead of a misleading "backend
205            // unreachable, retry".
206            || s.contains("not initialized")
207        {
208            KopiaErrorClass::NotFound
209        } else if s.contains("error connecting to repository")
210            || s.contains("unable to open repository")
211            // kopia's other open-failure phrasing, and the index/blob download
212            // failures behind it: a remote backend dropping connections while
213            // the index loads (issue #477). AFTER the AuthFailure/NotFound arms,
214            // so a failed open with a specific cause (bad password, not
215            // initialized) keeps its specific class.
216            || s.contains("failed to open repository")
217            || s.contains("error loading indexes")
218            || s.contains("unexpected eof")
219            || s.contains("connection refused")
220            || s.contains("dial tcp")
221            || s.contains("no route to host")
222            || s.contains("timeout")
223            // DNS resolution failure (Go's net resolver: `lookup <host> …: no
224            // such host`). Deliberately AFTER the NotFound arm above: none of
225            // its substrings ("no such file or directory", "not found", …)
226            // match this phrasing, and keeping it here means it can never
227            // shadow a genuine missing-path classification.
228            || s.contains("no such host")
229            // TLS / certificate failures reaching the backend (Go: `tls: …`,
230            // `x509: certificate signed by unknown authority`, `certificate
231            // has expired`). The endpoint is unreachable-as-configured — the
232            // fix is the endpoint/CA/trust config, and the repository gate
233            // should engage. AFTER the AuthFailure/AccessDenied arms above so
234            // "certificate" can never capture a credential/authorization
235            // message (those arms match their own specific phrasings first).
236            || s.contains("tls:")
237            || s.contains("x509")
238            || s.contains("certificate")
239        {
240            KopiaErrorClass::RepositoryUnavailable
241        } else if s.contains("upload error") || s.contains("failed to prepare source") {
242            KopiaErrorClass::SourceError
243        } else {
244            KopiaErrorClass::Unknown
245        }
246    }
247}
248
249/// Whether a [`KopiaErrorClass::NotFound`] connect failure is the backend
250/// reporting a *genuinely uninitialized* repository (kopia's
251/// `ErrRepositoryNotInitialized`: "repository not initialized in the provided
252/// storage") rather than a *missing path / mount* ("no such file or directory",
253/// "does not exist").
254///
255/// Both phrasings classify as [`KopiaErrorClass::NotFound`] (so first-bootstrap
256/// `create` still fires for either), but they mean very different things for an
257/// already-`Ready` repository under the health probe:
258///
259/// * genuine "not initialized" ⇒ the backend answered and the kopia format blob
260///   is gone — a candidate *vanished repository* (`RepositoryVanished`).
261/// * a missing path / mount ⇒ the PVC isn't bound or the export moved — a
262///   *backend/mount fault* (`BackendReachable=False`), NOT a wipe. Recreating
263///   here would be catastrophic, so the two must never be conflated.
264///
265/// Returns `false` for any non-`NotFound` stderr; callers gate on the class first.
266///
267/// ```
268/// use kopiur_kopia::notfound_is_uninitialized;
269/// assert!(notfound_is_uninitialized("repository not initialized in the provided storage"));
270/// assert!(!notfound_is_uninitialized("open /repo/kopia.repository: no such file or directory"));
271/// ```
272pub fn notfound_is_uninitialized(stderr: &str) -> bool {
273    stderr.to_ascii_lowercase().contains("not initialized")
274}
275
276/// Whether a **successful** (exit 0) `snapshot create` that produced no JSON on
277/// stdout was kopia deliberately declining to write a manifest because the
278/// source is byte-identical to the previous snapshot.
279///
280/// kopia's message is:
281///
282/// ```text
283///  Not saving snapshot because no files have been changed since previous snapshot
284/// ```
285///
286/// This is gated by the **retention**-policy knob `ignoreIdenticalSnapshots`
287/// (`*OptionalBool`, kopia default `false`). With it on, `snapshot create
288/// --json` exits 0, writes **nothing** to stdout, and says why only on stderr —
289/// which read as a hard `EmptyOutput` failure and terminally failed the
290/// `Snapshot` CR (#351).
291///
292/// Matched on the stable middle of the sentence rather than the whole string:
293/// kopia prefixes it with a leading space and has reworded the surrounding
294/// phrasing across releases, but "no files have been changed" has been
295/// constant. Case-insensitive for the same reason.
296///
297/// ```
298/// use kopiur_kopia::snapshot_skipped_unchanged;
299/// assert!(snapshot_skipped_unchanged(
300///     " Not saving snapshot because no files have been changed since previous snapshot"
301/// ));
302/// assert!(!snapshot_skipped_unchanged("Snapshotting app@host:/pvc/data ..."));
303/// assert!(!snapshot_skipped_unchanged(""));
304/// ```
305pub fn snapshot_skipped_unchanged(stderr: &str) -> bool {
306    stderr
307        .to_ascii_lowercase()
308        .contains("no files have been changed")
309}
310
311impl fmt::Display for KopiaErrorClass {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        f.write_str(self.as_str())
314    }
315}
316
317/// Errors produced while invoking kopia or parsing its `--json` output.
318///
319/// Each variant's `Display` is actionable — it names the operation, the failure,
320/// and (for non-zero exits) the error class plus the stderr tail — so it can be
321/// dropped straight into a `status.failure` block (ADR §4.10):
322///
323/// ```
324/// use kopiur_kopia::{KopiaError, KopiaErrorClass};
325///
326/// let err = KopiaError::NonZeroExit {
327///     args: "snapshot create".into(),
328///     code: Some(1),
329///     class: KopiaErrorClass::Locked,
330///     stderr_tail: "repository is locked by another process".into(),
331/// };
332/// assert_eq!(
333///     err.to_string(),
334///     "kopia `snapshot create` failed (exit code 1, class Locked): \
335///      repository is locked by another process",
336/// );
337/// // The class drives the retry decision; the stderr tail is recoverable.
338/// assert_eq!(err.class(), KopiaErrorClass::Locked);
339/// assert!(err.class().is_retryable());
340/// assert_eq!(err.stderr_tail(), Some("repository is locked by another process"));
341///
342/// // A timeout names the args and elapsed seconds, and maps to a retryable class.
343/// let to = KopiaError::Timeout { args: "maintenance run --full".into(), seconds: 3600 };
344/// assert_eq!(to.to_string(), "kopia `maintenance run --full` timed out after 3600s");
345/// assert_eq!(to.class(), KopiaErrorClass::RepositoryUnavailable);
346/// ```
347#[derive(thiserror::Error, Debug)]
348pub enum KopiaError {
349    /// Writing kopia's OUTPUT to the consumer failed. kopia spawned and ran
350    /// fine; the sink — a `streamExec` restore's exec stdin — broke.
351    ///
352    /// Separate from [`KopiaError::Spawn`] because it used to BE `Spawn`, and
353    /// that misattribution actively cost debugging time: a `streamExec` restore
354    /// whose consumer pipe broke reported "failed to spawn kopia binary `kopia`:
355    /// broken pipe", sending the reader after a missing binary that was in fact
356    /// running. The three fallible halves of the streaming copy — the sink write,
357    /// the stderr read, and the child wait — are not interchangeable and must not
358    /// share one error.
359    #[error("could not stream kopia `{args}` output into the consumer: {source}")]
360    OutputSink {
361        /// The kopia argv whose output was being streamed.
362        args: String,
363        /// Underlying I/O error from the SINK, not from kopia.
364        #[source]
365        source: std::io::Error,
366    },
367
368    /// The kopia binary could not be spawned at all (missing binary, not
369    /// executable, fork failure). Carries the OS error.
370    #[error("failed to spawn kopia binary `{binary}`: {source}")]
371    Spawn {
372        /// Path we attempted to execute.
373        binary: String,
374        /// Underlying I/O error.
375        #[source]
376        source: std::io::Error,
377    },
378
379    /// kopia ran but exited with a non-zero status. Carries everything needed
380    /// to build a `status.failure` block.
381    ///
382    /// `Display` renders a clean exit code (no `Some(1)` `Debug` leak) and a
383    /// **humanized** stderr extract (progress noise dropped, volatile temp-path
384    /// fragments stripped) — this is what reaches Warning Events,
385    /// `status.failure.message`, and logs. The full raw tail is preserved in the
386    /// `stderr_tail` field (surfaced via `status.failure.stderrTail`) for
387    /// debugging; read it with [`stderr_tail`](Self::stderr_tail).
388    #[error(
389        "kopia `{args}` failed ({}, class {class}): {}",
390        exit_code_desc(.code),
391        humanize_tail(.stderr_tail)
392    )]
393    NonZeroExit {
394        /// The subcommand + args that were run (for diagnostics; secrets are
395        /// passed via env, never argv).
396        args: String,
397        /// Process exit code, if one was reported (None if killed by signal).
398        code: Option<i32>,
399        /// Best-effort error classification from stderr.
400        class: KopiaErrorClass,
401        /// The last [`STDERR_TAIL_LINES`] lines of stderr, joined by newlines.
402        stderr_tail: String,
403    },
404
405    /// kopia exited 0 (or produced output) but the JSON could not be parsed
406    /// into the expected type — usually a kopia version skew.
407    #[error("failed to parse kopia JSON output for `{context}`: {source}")]
408    Json {
409        /// What we were trying to parse (e.g. "snapshot create result").
410        context: String,
411        /// The serde error.
412        #[source]
413        source: serde_json::Error,
414    },
415
416    /// The process feeding kopia's stdin did not finish successfully, so the
417    /// snapshot was deliberately aborted before kopia wrote any manifest.
418    ///
419    /// Distinct from [`KopiaError::NonZeroExit`]: kopia itself was fine — it was
420    /// killed on purpose. Surfacing that difference is what tells an operator
421    /// "your dump command failed" rather than "the backup tool failed".
422    #[error(
423        "snapshot aborted: {detail}{}",
424        if stderr_tail.is_empty() { String::new() } else { format!(" (kopia stderr: {stderr_tail})") }
425    )]
426    StdinProducerFailed {
427        /// What went wrong with the producer, already free of any streamed data.
428        detail: String,
429        /// Bounded tail of kopia's own stderr, for context.
430        stderr_tail: String,
431    },
432
433    /// The stdin producer exited **successfully** having written ZERO bytes, so
434    /// the snapshot was aborted before any manifest was written.
435    ///
436    /// Deliberately a failure rather than an empty snapshot. A producer's exit
437    /// status cannot be trusted to catch this on its own: `pg_dump` exits 0
438    /// against an instance it cannot see any databases in, `mysqldump` exits 0
439    /// when its credentials grant nothing, and a `sh -c 'a | b'` pipeline reports
440    /// only `b`'s status. Committing the result would put a zero-byte "restore
441    /// point" into the repository that retention keeps and a restore would
442    /// cheerfully write over a live database with.
443    #[error(
444        "snapshot aborted: the stdin producer exited 0 but wrote no data, so nothing was saved. \
445         Fix: make sure `workloadExec.command` writes to stdout (a dump with no visible \
446         databases can print nothing); in a `sh -c` pipeline add `set -o pipefail`. Args: \
447         {args}{}",
448        if stderr_tail.is_empty() { String::new() } else { format!(" (kopia stderr: {stderr_tail})") }
449    )]
450    StdinProducerWroteNothing {
451        /// The kopia argv, for correlation with the Job log.
452        args: String,
453        /// Bounded tail of kopia's own stderr. NEVER the producer's stdout — that
454        /// is the user's data and this crate offers no path by which it could
455        /// reach an error message.
456        stderr_tail: String,
457    },
458
459    /// We expected a JSON object/array on stdout but found none (kopia printed
460    /// only progress / nothing) even though it exited **0**.
461    ///
462    /// `stderr_tail` is what makes this diagnosable. Without it the variant
463    /// carried neither a reason nor an exit code, so a kopia that exited
464    /// cleanly and explained itself on stderr was indistinguishable from a
465    /// kopia that said nothing at all — the whole of #351.
466    #[error("no JSON output found on stdout for `{context}`: {stderr_tail}")]
467    EmptyOutput {
468        /// What we were trying to parse.
469        context: String,
470        /// The trailing stderr lines, which is where kopia explains a silent
471        /// success. Empty when kopia really did say nothing.
472        stderr_tail: String,
473    },
474
475    /// The operation exceeded its configured timeout and was killed.
476    #[error("kopia `{args}` timed out after {seconds}s")]
477    Timeout {
478        /// The subcommand + args that were run.
479        args: String,
480        /// The timeout that elapsed, in seconds.
481        seconds: u64,
482    },
483}
484
485impl KopiaError {
486    /// The error class for this error, for retry decisions and metrics. Spawn,
487    /// JSON-parse, empty-output, and timeout errors map to a fixed class;
488    /// non-zero exits carry their own classification.
489    pub fn class(&self) -> KopiaErrorClass {
490        match self {
491            KopiaError::NonZeroExit { class, .. } => *class,
492            // A spawn failure is environmental (bad image / missing binary) —
493            // retrying the same pod won't help, treat as Unknown/non-retryable.
494            KopiaError::Spawn { .. } => KopiaErrorClass::Unknown,
495            KopiaError::Json { .. } | KopiaError::EmptyOutput { .. } => KopiaErrorClass::Unknown,
496            // The repository was never at fault — the consumer's pipe was. Re-running
497            // re-runs the same consumer command, so this is terminal for the operator.
498            KopiaError::OutputSink { .. } => KopiaErrorClass::Unknown,
499            // Timeouts are usually a slow backend → worth a retry.
500            KopiaError::Timeout { .. } => KopiaErrorClass::RepositoryUnavailable,
501            // Both stdin-producer failures: the repository was never at fault —
502            // the user's dump command was. Re-running the same command would
503            // reproduce the same failure, or the same empty output, so the fix
504            // is in the workload or the policy, never in another attempt.
505            //
506            // They share ONE arm deliberately. These two comments used to sit on
507            // separate arms and drifted: one kept the honest scope below while
508            // the other went on claiming outright that the failure is not
509            // retried. Same class, same reasoning, one arm — so the claim cannot
510            // rot on only one half again.
511            //
512            // NOTE what this class does and does NOT do. It makes the failure
513            // TERMINAL for the operator: `MoverError::retry_recommended()` is
514            // false, so `status.failure.retryRecommended` says so and the
515            // controller does not treat the run as retryable. It does NOT stop
516            // the Kubernetes Job from retrying — the mover exits non-zero and the
517            // Job's own `backoffLimit` (default 2) may schedule replacement pods
518            // that re-exec the producer's command. Preventing that needs the Job
519            // DELETED, the way the wedged-pod path in `controller::snapshot`
520            // does; nothing reads `retryRecommended` today. Tracked separately.
521            KopiaError::StdinProducerFailed { .. }
522            | KopiaError::StdinProducerWroteNothing { .. } => KopiaErrorClass::Unknown,
523        }
524    }
525
526    /// The trailing stderr lines, if this error captured any.
527    pub fn stderr_tail(&self) -> Option<&str> {
528        match self {
529            KopiaError::NonZeroExit { stderr_tail, .. } => Some(stderr_tail.as_str()),
530            // An exit-0-with-no-JSON keeps its stderr too, so `status.failure`
531            // shows kopia's own words instead of a bare "class Unknown".
532            KopiaError::EmptyOutput { stderr_tail, .. } if !stderr_tail.is_empty() => {
533                Some(stderr_tail.as_str())
534            }
535            // Bounded kopia stderr only. The producer's own diagnostics ride in
536            // `detail`, and its STDOUT — the backup data — is never captured at all.
537            KopiaError::StdinProducerFailed { stderr_tail, .. }
538            | KopiaError::StdinProducerWroteNothing { stderr_tail, .. }
539                if !stderr_tail.is_empty() =>
540            {
541                Some(stderr_tail.as_str())
542            }
543            _ => None,
544        }
545    }
546}
547
548/// Keep only the last `STDERR_TAIL_LINES` non-empty-trimmed lines of a stderr
549/// blob, joined by newlines. Used when building a [`KopiaError::NonZeroExit`].
550pub(crate) fn tail_lines(stderr: &str) -> String {
551    let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
552    let start = lines.len().saturating_sub(STDERR_TAIL_LINES);
553    lines[start..].join("\n")
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn classify_known_patterns() {
562        assert_eq!(
563            KopiaErrorClass::classify("ERROR error connecting to repository: dial tcp ..."),
564            KopiaErrorClass::RepositoryUnavailable
565        );
566        assert_eq!(
567            KopiaErrorClass::classify("invalid repository password"),
568            KopiaErrorClass::AuthFailure
569        );
570        assert_eq!(
571            KopiaErrorClass::classify("lstat /nope: no such file or directory"),
572            KopiaErrorClass::NotFound
573        );
574        assert_eq!(
575            KopiaErrorClass::classify("repository is locked by another process"),
576            KopiaErrorClass::Locked
577        );
578        assert_eq!(
579            KopiaErrorClass::classify("upload error: unsupported source"),
580            KopiaErrorClass::SourceError
581        );
582        assert_eq!(
583            KopiaErrorClass::classify("something totally unexpected"),
584            KopiaErrorClass::Unknown
585        );
586    }
587
588    /// Issue #477, verbatim: a remote backend dropping connections while the
589    /// index loads. The repository failed to OPEN, a transient backend fault,
590    /// so it must be retryable. As `Unknown` the batch-delete fallback would
591    /// re-open the same struggling repository once per member.
592    #[test]
593    fn classify_index_load_failures_as_repository_unavailable() {
594        let issue_477 = "failed to open repository: unable to create shared content manager: \
595             error loading indexes: error downloading indexes: error loading index blob \
596             xn45_abc: getContent: unable to complete GetBlob(xn45_abc,0,-1) despite 10 \
597             retries: unexpected EOF";
598        assert_eq!(
599            KopiaErrorClass::classify(issue_477),
600            KopiaErrorClass::RepositoryUnavailable
601        );
602        assert_eq!(
603            KopiaErrorClass::classify("error reading blob: unexpected EOF"),
604            KopiaErrorClass::RepositoryUnavailable
605        );
606        // The earlier arms still win on a failed open with a specific cause.
607        assert_eq!(
608            KopiaErrorClass::classify("failed to open repository: invalid repository password"),
609            KopiaErrorClass::AuthFailure
610        );
611        assert_eq!(
612            KopiaErrorClass::classify("failed to open repository: repository not initialized"),
613            KopiaErrorClass::NotFound
614        );
615    }
616
617    #[test]
618    fn classify_dns_and_tls_failures_as_repository_unavailable() {
619        // DNS resolution failure (Go's net resolver phrasing): the backend
620        // hostname doesn't resolve — the backend is unreachable, the gate
621        // should engage (#345).
622        assert_eq!(
623            KopiaErrorClass::classify(
624                "unable to open repository: lookup minio.storage.svc on 10.96.0.10:53: \
625                 no such host"
626            ),
627            KopiaErrorClass::RepositoryUnavailable
628        );
629        assert_eq!(
630            KopiaErrorClass::classify("dial tcp: lookup s3.example.com: no such host"),
631            KopiaErrorClass::RepositoryUnavailable
632        );
633        // TLS handshake / certificate trust failures: unreachable-as-configured.
634        assert_eq!(
635            KopiaErrorClass::classify("tls: failed to verify certificate"),
636            KopiaErrorClass::RepositoryUnavailable
637        );
638        assert_eq!(
639            KopiaErrorClass::classify("x509: certificate signed by unknown authority"),
640            KopiaErrorClass::RepositoryUnavailable
641        );
642        assert_eq!(
643            KopiaErrorClass::classify("certificate has expired or is not yet valid"),
644            KopiaErrorClass::RepositoryUnavailable
645        );
646        // All of these are transient/config-external → retryable.
647        assert!(KopiaErrorClass::RepositoryUnavailable.is_retryable());
648    }
649
650    #[test]
651    fn widened_arms_do_not_shadow_existing_classifications() {
652        // The new DNS/TLS substrings sit in the RepositoryUnavailable arm,
653        // AFTER every more-specific arm — existing classifications must be
654        // byte-for-byte unchanged.
655        assert_eq!(
656            KopiaErrorClass::classify("invalid repository password"),
657            KopiaErrorClass::AuthFailure
658        );
659        assert_eq!(
660            KopiaErrorClass::classify("access denied"),
661            KopiaErrorClass::AccessDenied
662        );
663        assert_eq!(
664            KopiaErrorClass::classify("no such file or directory"),
665            KopiaErrorClass::NotFound
666        );
667        // A certificate-flavored message that ALSO carries an auth/authz
668        // phrasing still classifies by the earlier, more specific arm.
669        assert_eq!(
670            KopiaErrorClass::classify("certificate auth: access denied"),
671            KopiaErrorClass::AccessDenied
672        );
673        // "not initialized" (empty backend) keeps winning over the connect
674        // prefix — the ordering the NotFound arm's comment documents.
675        assert_eq!(
676            KopiaErrorClass::classify(
677                "error connecting to repository: repository not initialized in the \
678                 provided storage"
679            ),
680            KopiaErrorClass::NotFound
681        );
682    }
683
684    #[test]
685    fn classify_uninitialized_repository_as_not_found() {
686        // Regression: connecting to an empty backend (no kopia repo at the prefix)
687        // makes kopia emit `repo.ErrRepositoryNotInitialized`, which the CLI wraps
688        // with its generic connect prefix. That prefix matches the
689        // RepositoryUnavailable arm, so without an explicit "not initialized" check
690        // the empty-bucket case was misclassified as a transient unreachable backend
691        // — and the mover's `not_initialized()` path (keyed on NotFound) never fired,
692        // so the operator saw "backend unreachable; retry" instead of the actionable
693        // "set spec.create.enabled: true". It must classify as NotFound.
694        assert_eq!(
695            KopiaErrorClass::classify(
696                "ERROR error connecting to repository: repository not initialized in the \
697                 provided storage"
698            ),
699            KopiaErrorClass::NotFound
700        );
701        // Bare form (no connect prefix) classifies the same way.
702        assert_eq!(
703            KopiaErrorClass::classify("repository not initialized in the provided storage"),
704            KopiaErrorClass::NotFound
705        );
706        // NotFound is non-retryable: the fix is a spec change, not a blind retry.
707        assert!(!KopiaErrorClass::NotFound.is_retryable());
708    }
709
710    #[test]
711    fn notfound_distinguishes_uninitialized_from_missing_path() {
712        // A genuinely empty backend (format blob absent) → uninitialized: the health
713        // probe may treat this as a candidate "vanished" repository.
714        assert!(notfound_is_uninitialized(
715            "ERROR error connecting to repository: repository not initialized in the \
716             provided storage"
717        ));
718        // A missing path / unbound mount also classifies NotFound, but is a backend/
719        // mount fault — NOT an empty repository. Must NOT read as uninitialized, so the
720        // probe never misreads a mis-mounted volume as a wipe (and never nudges a recreate).
721        assert!(!notfound_is_uninitialized(
722            "open /repo/kopia.repository: no such file or directory"
723        ));
724        assert!(!notfound_is_uninitialized("stat /mnt/nas: does not exist"));
725        // Both still classify as NotFound (so first-bootstrap `create` fires for either).
726        assert_eq!(
727            KopiaErrorClass::classify("open /repo/kopia.repository: no such file or directory"),
728            KopiaErrorClass::NotFound
729        );
730    }
731
732    #[test]
733    fn classify_access_denied_and_permission_denied() {
734        // The exact RustFS/S3 message we observed live (bucket missing, masked as
735        // Access Denied) must classify as AccessDenied, not Unknown.
736        assert_eq!(
737            KopiaErrorClass::classify(
738                "can't connect to storage: error retrieving storage config from bucket \
739                 \"kopiur\": Access Denied"
740            ),
741            KopiaErrorClass::AccessDenied
742        );
743        assert_eq!(
744            KopiaErrorClass::classify("403 Forbidden"),
745            KopiaErrorClass::AccessDenied
746        );
747        // Filesystem repo path not writable by our UID → PermissionDenied, NOT
748        // the old SourceError (which marked it retryable).
749        assert_eq!(
750            KopiaErrorClass::classify("unable to create directory /repo: permission denied"),
751            KopiaErrorClass::PermissionDenied
752        );
753        assert_eq!(
754            KopiaErrorClass::classify("open /repo/kopia.repository: operation not permitted"),
755            KopiaErrorClass::PermissionDenied
756        );
757    }
758
759    #[test]
760    fn from_label_roundtrips_every_variant() {
761        for c in [
762            KopiaErrorClass::RepositoryUnavailable,
763            KopiaErrorClass::AuthFailure,
764            KopiaErrorClass::AccessDenied,
765            KopiaErrorClass::PermissionDenied,
766            KopiaErrorClass::NotFound,
767            KopiaErrorClass::Locked,
768            KopiaErrorClass::SourceError,
769            KopiaErrorClass::Unknown,
770        ] {
771            assert_eq!(KopiaErrorClass::from_label(c.as_str()), c);
772        }
773        assert_eq!(
774            KopiaErrorClass::from_label("not-a-real-class"),
775            KopiaErrorClass::Unknown
776        );
777    }
778
779    #[test]
780    fn summary_is_stable_and_volatile_free() {
781        // Every class yields a non-empty, stable summary with no per-attempt
782        // volatile content (the temp-filename suffix kopia emits in stderr must
783        // never leak into the condition message — that volatility is what caused
784        // the reconcile hot-loop).
785        for c in [
786            KopiaErrorClass::RepositoryUnavailable,
787            KopiaErrorClass::AuthFailure,
788            KopiaErrorClass::AccessDenied,
789            KopiaErrorClass::PermissionDenied,
790            KopiaErrorClass::NotFound,
791            KopiaErrorClass::Locked,
792            KopiaErrorClass::SourceError,
793            KopiaErrorClass::Unknown,
794        ] {
795            let s = c.summary();
796            assert!(!s.is_empty());
797            assert!(
798                !s.contains(".shards"),
799                "summary leaks a volatile temp path: {s}"
800            );
801            assert!(
802                !s.contains(".tmp"),
803                "summary leaks a volatile temp path: {s}"
804            );
805            // Stable across calls (it returns a 'static str, but assert intent).
806            assert_eq!(s, c.summary());
807        }
808        // The PermissionDenied summary is the actionable one for the reported bug.
809        assert!(
810            KopiaErrorClass::PermissionDenied
811                .summary()
812                .contains("not writable")
813        );
814    }
815
816    #[test]
817    fn retryable_classification() {
818        assert!(KopiaErrorClass::RepositoryUnavailable.is_retryable());
819        assert!(KopiaErrorClass::Locked.is_retryable());
820        assert!(!KopiaErrorClass::AuthFailure.is_retryable());
821        assert!(!KopiaErrorClass::AccessDenied.is_retryable());
822        assert!(!KopiaErrorClass::PermissionDenied.is_retryable());
823        assert!(!KopiaErrorClass::NotFound.is_retryable());
824        assert!(!KopiaErrorClass::Unknown.is_retryable());
825    }
826
827    #[test]
828    fn tail_keeps_last_lines() {
829        let blob: String = (0..50)
830            .map(|i| format!("line {i}\n"))
831            .collect::<Vec<_>>()
832            .join("");
833        let tail = tail_lines(&blob);
834        let kept: Vec<&str> = tail.lines().collect();
835        assert_eq!(kept.len(), STDERR_TAIL_LINES);
836        assert_eq!(*kept.last().unwrap(), "line 49");
837    }
838
839    #[test]
840    fn error_class_propagation() {
841        let e = KopiaError::NonZeroExit {
842            args: "snapshot create".into(),
843            code: Some(1),
844            class: KopiaErrorClass::Locked,
845            stderr_tail: "repository is locked".into(),
846        };
847        assert_eq!(e.class(), KopiaErrorClass::Locked);
848        assert_eq!(e.stderr_tail(), Some("repository is locked"));
849    }
850}