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 and is reachable"
127            }
128            KopiaErrorClass::PermissionDenied => {
129                "repository path is not writable by the operator's UID; fix ownership/mode on the \
130                 backing PVC/NFS export"
131            }
132            KopiaErrorClass::NotFound => {
133                "the requested repository path, snapshot, or target was not found; verify the \
134                 backend path/prefix and that the repository exists"
135            }
136            KopiaErrorClass::Locked => {
137                "a repository lock is held by another writer; it usually clears on retry"
138            }
139            KopiaErrorClass::SourceError => {
140                "a source filesystem error occurred during upload; check the source volume and the \
141                 mover Job/pod logs"
142            }
143            KopiaErrorClass::Unknown => {
144                "an unclassified repository backend error occurred; see the mover Job/pod logs and \
145                 status.failure for detail"
146            }
147        }
148    }
149
150    /// Whether re-running the same operation later might succeed without any
151    /// configuration change. This is the operator's default retry hint; the
152    /// caller may override it with policy.
153    pub fn is_retryable(&self) -> bool {
154        matches!(
155            self,
156            KopiaErrorClass::RepositoryUnavailable
157                | KopiaErrorClass::Locked
158                | KopiaErrorClass::SourceError
159        )
160    }
161
162    /// Best-effort classification from captured stderr text. Matches against
163    /// substrings kopia is observed to emit (kopia 0.23). Order matters: more
164    /// specific checks come first.
165    pub fn classify(stderr: &str) -> KopiaErrorClass {
166        let s = stderr.to_ascii_lowercase();
167        if s.contains("invalid repository password")
168            || s.contains("incorrect password")
169            || s.contains("unable to derive")
170        {
171            KopiaErrorClass::AuthFailure
172        } else if s.contains("access denied")
173            || s.contains("accessdenied")
174            || s.contains("forbidden")
175            || s.contains("not authorized")
176        {
177            // Backend authorization (e.g. S3 "Access Denied"). Checked before the
178            // generic permission/not-found arms because the backend phrasing is
179            // specific and the fix (creds/bucket) is distinct.
180            KopiaErrorClass::AccessDenied
181        } else if s.contains("permission denied")
182            || s.contains("operation not permitted")
183            || s.contains("eacces")
184        {
185            // Local repo path not writable by our UID. Checked before SourceError
186            // (which used to absorb "permission denied" and wrongly mark it
187            // retryable) and before NotFound.
188            KopiaErrorClass::PermissionDenied
189        } else if s.contains("repository is locked")
190            || s.contains("another process")
191            || s.contains("lock")
192        {
193            KopiaErrorClass::Locked
194        } else if s.contains("no such file or directory")
195            || s.contains("not found")
196            || s.contains("does not exist")
197            || s.contains("unable to find snapshot")
198            // kopia's `repo.ErrRepositoryNotInitialized` ("repository not initialized
199            // in the provided storage") — an *empty* backend with no kopia repo at the
200            // prefix. The CLI wraps it as `error connecting to repository: repository
201            // not initialized ...`, so it MUST be matched here, ahead of the
202            // RepositoryUnavailable arm, or an uninitialized repo is misread as an
203            // unreachable backend. Classifying it `NotFound` is what lets the mover
204            // surface the actionable `RepositoryNotInitialized` outcome (set
205            // `spec.create.enabled: true`) instead of a misleading "backend
206            // unreachable, retry".
207            || s.contains("not initialized")
208        {
209            KopiaErrorClass::NotFound
210        } else if s.contains("error connecting to repository")
211            || s.contains("unable to open repository")
212            || s.contains("connection refused")
213            || s.contains("dial tcp")
214            || s.contains("no route to host")
215            || s.contains("timeout")
216            // DNS resolution failure (Go's net resolver: `lookup <host> …: no
217            // such host`). Deliberately AFTER the NotFound arm above: none of
218            // its substrings ("no such file or directory", "not found", …)
219            // match this phrasing, and keeping it here means it can never
220            // shadow a genuine missing-path classification.
221            || s.contains("no such host")
222            // TLS / certificate failures reaching the backend (Go: `tls: …`,
223            // `x509: certificate signed by unknown authority`, `certificate
224            // has expired`). The endpoint is unreachable-as-configured — the
225            // fix is the endpoint/CA/trust config, and the repository gate
226            // should engage. AFTER the AuthFailure/AccessDenied arms above so
227            // "certificate" can never capture a credential/authorization
228            // message (those arms match their own specific phrasings first).
229            || s.contains("tls:")
230            || s.contains("x509")
231            || s.contains("certificate")
232        {
233            KopiaErrorClass::RepositoryUnavailable
234        } else if s.contains("upload error") || s.contains("failed to prepare source") {
235            KopiaErrorClass::SourceError
236        } else {
237            KopiaErrorClass::Unknown
238        }
239    }
240}
241
242/// Whether a [`KopiaErrorClass::NotFound`] connect failure is the backend
243/// reporting a *genuinely uninitialized* repository (kopia's
244/// `ErrRepositoryNotInitialized`: "repository not initialized in the provided
245/// storage") rather than a *missing path / mount* ("no such file or directory",
246/// "does not exist").
247///
248/// Both phrasings classify as [`KopiaErrorClass::NotFound`] (so first-bootstrap
249/// `create` still fires for either), but they mean very different things for an
250/// already-`Ready` repository under the health probe:
251///
252/// * genuine "not initialized" ⇒ the backend answered and the kopia format blob
253///   is gone — a candidate *vanished repository* (`RepositoryVanished`).
254/// * a missing path / mount ⇒ the PVC isn't bound or the export moved — a
255///   *backend/mount fault* (`BackendReachable=False`), NOT a wipe. Recreating
256///   here would be catastrophic, so the two must never be conflated.
257///
258/// Returns `false` for any non-`NotFound` stderr; callers gate on the class first.
259///
260/// ```
261/// use kopiur_kopia::notfound_is_uninitialized;
262/// assert!(notfound_is_uninitialized("repository not initialized in the provided storage"));
263/// assert!(!notfound_is_uninitialized("open /repo/kopia.repository: no such file or directory"));
264/// ```
265pub fn notfound_is_uninitialized(stderr: &str) -> bool {
266    stderr.to_ascii_lowercase().contains("not initialized")
267}
268
269/// Whether a **successful** (exit 0) `snapshot create` that produced no JSON on
270/// stdout was kopia deliberately declining to write a manifest because the
271/// source is byte-identical to the previous snapshot.
272///
273/// kopia's message is:
274///
275/// ```text
276///  Not saving snapshot because no files have been changed since previous snapshot
277/// ```
278///
279/// This is gated by the **retention**-policy knob `ignoreIdenticalSnapshots`
280/// (`*OptionalBool`, kopia default `false`). With it on, `snapshot create
281/// --json` exits 0, writes **nothing** to stdout, and says why only on stderr —
282/// which read as a hard `EmptyOutput` failure and terminally failed the
283/// `Snapshot` CR (#351).
284///
285/// Matched on the stable middle of the sentence rather than the whole string:
286/// kopia prefixes it with a leading space and has reworded the surrounding
287/// phrasing across releases, but "no files have been changed" has been
288/// constant. Case-insensitive for the same reason.
289///
290/// ```
291/// use kopiur_kopia::snapshot_skipped_unchanged;
292/// assert!(snapshot_skipped_unchanged(
293///     " Not saving snapshot because no files have been changed since previous snapshot"
294/// ));
295/// assert!(!snapshot_skipped_unchanged("Snapshotting app@host:/pvc/data ..."));
296/// assert!(!snapshot_skipped_unchanged(""));
297/// ```
298pub fn snapshot_skipped_unchanged(stderr: &str) -> bool {
299    stderr
300        .to_ascii_lowercase()
301        .contains("no files have been changed")
302}
303
304impl fmt::Display for KopiaErrorClass {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        f.write_str(self.as_str())
307    }
308}
309
310/// Errors produced while invoking kopia or parsing its `--json` output.
311///
312/// Each variant's `Display` is actionable — it names the operation, the failure,
313/// and (for non-zero exits) the error class plus the stderr tail — so it can be
314/// dropped straight into a `status.failure` block (ADR §4.10):
315///
316/// ```
317/// use kopiur_kopia::{KopiaError, KopiaErrorClass};
318///
319/// let err = KopiaError::NonZeroExit {
320///     args: "snapshot create".into(),
321///     code: Some(1),
322///     class: KopiaErrorClass::Locked,
323///     stderr_tail: "repository is locked by another process".into(),
324/// };
325/// assert_eq!(
326///     err.to_string(),
327///     "kopia `snapshot create` failed (exit code 1, class Locked): \
328///      repository is locked by another process",
329/// );
330/// // The class drives the retry decision; the stderr tail is recoverable.
331/// assert_eq!(err.class(), KopiaErrorClass::Locked);
332/// assert!(err.class().is_retryable());
333/// assert_eq!(err.stderr_tail(), Some("repository is locked by another process"));
334///
335/// // A timeout names the args and elapsed seconds, and maps to a retryable class.
336/// let to = KopiaError::Timeout { args: "maintenance run --full".into(), seconds: 3600 };
337/// assert_eq!(to.to_string(), "kopia `maintenance run --full` timed out after 3600s");
338/// assert_eq!(to.class(), KopiaErrorClass::RepositoryUnavailable);
339/// ```
340#[derive(thiserror::Error, Debug)]
341pub enum KopiaError {
342    /// The kopia binary could not be spawned at all (missing binary, not
343    /// executable, fork failure). Carries the OS error.
344    #[error("failed to spawn kopia binary `{binary}`: {source}")]
345    Spawn {
346        /// Path we attempted to execute.
347        binary: String,
348        /// Underlying I/O error.
349        #[source]
350        source: std::io::Error,
351    },
352
353    /// kopia ran but exited with a non-zero status. Carries everything needed
354    /// to build a `status.failure` block.
355    ///
356    /// `Display` renders a clean exit code (no `Some(1)` `Debug` leak) and a
357    /// **humanized** stderr extract (progress noise dropped, volatile temp-path
358    /// fragments stripped) — this is what reaches Warning Events,
359    /// `status.failure.message`, and logs. The full raw tail is preserved in the
360    /// `stderr_tail` field (surfaced via `status.failure.stderrTail`) for
361    /// debugging; read it with [`stderr_tail`](Self::stderr_tail).
362    #[error(
363        "kopia `{args}` failed ({}, class {class}): {}",
364        exit_code_desc(.code),
365        humanize_tail(.stderr_tail)
366    )]
367    NonZeroExit {
368        /// The subcommand + args that were run (for diagnostics; secrets are
369        /// passed via env, never argv).
370        args: String,
371        /// Process exit code, if one was reported (None if killed by signal).
372        code: Option<i32>,
373        /// Best-effort error classification from stderr.
374        class: KopiaErrorClass,
375        /// The last [`STDERR_TAIL_LINES`] lines of stderr, joined by newlines.
376        stderr_tail: String,
377    },
378
379    /// kopia exited 0 (or produced output) but the JSON could not be parsed
380    /// into the expected type — usually a kopia version skew.
381    #[error("failed to parse kopia JSON output for `{context}`: {source}")]
382    Json {
383        /// What we were trying to parse (e.g. "snapshot create result").
384        context: String,
385        /// The serde error.
386        #[source]
387        source: serde_json::Error,
388    },
389
390    /// We expected a JSON object/array on stdout but found none (kopia printed
391    /// only progress / nothing) even though it exited **0**.
392    ///
393    /// `stderr_tail` is what makes this diagnosable. Without it the variant
394    /// carried neither a reason nor an exit code, so a kopia that exited
395    /// cleanly and explained itself on stderr was indistinguishable from a
396    /// kopia that said nothing at all — the whole of #351.
397    #[error("no JSON output found on stdout for `{context}`: {stderr_tail}")]
398    EmptyOutput {
399        /// What we were trying to parse.
400        context: String,
401        /// The trailing stderr lines, which is where kopia explains a silent
402        /// success. Empty when kopia really did say nothing.
403        stderr_tail: String,
404    },
405
406    /// The operation exceeded its configured timeout and was killed.
407    #[error("kopia `{args}` timed out after {seconds}s")]
408    Timeout {
409        /// The subcommand + args that were run.
410        args: String,
411        /// The timeout that elapsed, in seconds.
412        seconds: u64,
413    },
414}
415
416impl KopiaError {
417    /// The error class for this error, for retry decisions and metrics. Spawn,
418    /// JSON-parse, empty-output, and timeout errors map to a fixed class;
419    /// non-zero exits carry their own classification.
420    pub fn class(&self) -> KopiaErrorClass {
421        match self {
422            KopiaError::NonZeroExit { class, .. } => *class,
423            // A spawn failure is environmental (bad image / missing binary) —
424            // retrying the same pod won't help, treat as Unknown/non-retryable.
425            KopiaError::Spawn { .. } => KopiaErrorClass::Unknown,
426            KopiaError::Json { .. } | KopiaError::EmptyOutput { .. } => KopiaErrorClass::Unknown,
427            // Timeouts are usually a slow backend → worth a retry.
428            KopiaError::Timeout { .. } => KopiaErrorClass::RepositoryUnavailable,
429        }
430    }
431
432    /// The trailing stderr lines, if this error captured any.
433    pub fn stderr_tail(&self) -> Option<&str> {
434        match self {
435            KopiaError::NonZeroExit { stderr_tail, .. } => Some(stderr_tail.as_str()),
436            // An exit-0-with-no-JSON keeps its stderr too, so `status.failure`
437            // shows kopia's own words instead of a bare "class Unknown".
438            KopiaError::EmptyOutput { stderr_tail, .. } if !stderr_tail.is_empty() => {
439                Some(stderr_tail.as_str())
440            }
441            _ => None,
442        }
443    }
444}
445
446/// Keep only the last `STDERR_TAIL_LINES` non-empty-trimmed lines of a stderr
447/// blob, joined by newlines. Used when building a [`KopiaError::NonZeroExit`].
448pub(crate) fn tail_lines(stderr: &str) -> String {
449    let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
450    let start = lines.len().saturating_sub(STDERR_TAIL_LINES);
451    lines[start..].join("\n")
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn classify_known_patterns() {
460        assert_eq!(
461            KopiaErrorClass::classify("ERROR error connecting to repository: dial tcp ..."),
462            KopiaErrorClass::RepositoryUnavailable
463        );
464        assert_eq!(
465            KopiaErrorClass::classify("invalid repository password"),
466            KopiaErrorClass::AuthFailure
467        );
468        assert_eq!(
469            KopiaErrorClass::classify("lstat /nope: no such file or directory"),
470            KopiaErrorClass::NotFound
471        );
472        assert_eq!(
473            KopiaErrorClass::classify("repository is locked by another process"),
474            KopiaErrorClass::Locked
475        );
476        assert_eq!(
477            KopiaErrorClass::classify("upload error: unsupported source"),
478            KopiaErrorClass::SourceError
479        );
480        assert_eq!(
481            KopiaErrorClass::classify("something totally unexpected"),
482            KopiaErrorClass::Unknown
483        );
484    }
485
486    #[test]
487    fn classify_dns_and_tls_failures_as_repository_unavailable() {
488        // DNS resolution failure (Go's net resolver phrasing): the backend
489        // hostname doesn't resolve — the backend is unreachable, the gate
490        // should engage (#345).
491        assert_eq!(
492            KopiaErrorClass::classify(
493                "unable to open repository: lookup minio.storage.svc on 10.96.0.10:53: \
494                 no such host"
495            ),
496            KopiaErrorClass::RepositoryUnavailable
497        );
498        assert_eq!(
499            KopiaErrorClass::classify("dial tcp: lookup s3.example.com: no such host"),
500            KopiaErrorClass::RepositoryUnavailable
501        );
502        // TLS handshake / certificate trust failures: unreachable-as-configured.
503        assert_eq!(
504            KopiaErrorClass::classify("tls: failed to verify certificate"),
505            KopiaErrorClass::RepositoryUnavailable
506        );
507        assert_eq!(
508            KopiaErrorClass::classify("x509: certificate signed by unknown authority"),
509            KopiaErrorClass::RepositoryUnavailable
510        );
511        assert_eq!(
512            KopiaErrorClass::classify("certificate has expired or is not yet valid"),
513            KopiaErrorClass::RepositoryUnavailable
514        );
515        // All of these are transient/config-external → retryable.
516        assert!(KopiaErrorClass::RepositoryUnavailable.is_retryable());
517    }
518
519    #[test]
520    fn widened_arms_do_not_shadow_existing_classifications() {
521        // The new DNS/TLS substrings sit in the RepositoryUnavailable arm,
522        // AFTER every more-specific arm — existing classifications must be
523        // byte-for-byte unchanged.
524        assert_eq!(
525            KopiaErrorClass::classify("invalid repository password"),
526            KopiaErrorClass::AuthFailure
527        );
528        assert_eq!(
529            KopiaErrorClass::classify("access denied"),
530            KopiaErrorClass::AccessDenied
531        );
532        assert_eq!(
533            KopiaErrorClass::classify("no such file or directory"),
534            KopiaErrorClass::NotFound
535        );
536        // A certificate-flavored message that ALSO carries an auth/authz
537        // phrasing still classifies by the earlier, more specific arm.
538        assert_eq!(
539            KopiaErrorClass::classify("certificate auth: access denied"),
540            KopiaErrorClass::AccessDenied
541        );
542        // "not initialized" (empty backend) keeps winning over the connect
543        // prefix — the ordering the NotFound arm's comment documents.
544        assert_eq!(
545            KopiaErrorClass::classify(
546                "error connecting to repository: repository not initialized in the \
547                 provided storage"
548            ),
549            KopiaErrorClass::NotFound
550        );
551    }
552
553    #[test]
554    fn classify_uninitialized_repository_as_not_found() {
555        // Regression: connecting to an empty backend (no kopia repo at the prefix)
556        // makes kopia emit `repo.ErrRepositoryNotInitialized`, which the CLI wraps
557        // with its generic connect prefix. That prefix matches the
558        // RepositoryUnavailable arm, so without an explicit "not initialized" check
559        // the empty-bucket case was misclassified as a transient unreachable backend
560        // — and the mover's `not_initialized()` path (keyed on NotFound) never fired,
561        // so the operator saw "backend unreachable; retry" instead of the actionable
562        // "set spec.create.enabled: true". It must classify as NotFound.
563        assert_eq!(
564            KopiaErrorClass::classify(
565                "ERROR error connecting to repository: repository not initialized in the \
566                 provided storage"
567            ),
568            KopiaErrorClass::NotFound
569        );
570        // Bare form (no connect prefix) classifies the same way.
571        assert_eq!(
572            KopiaErrorClass::classify("repository not initialized in the provided storage"),
573            KopiaErrorClass::NotFound
574        );
575        // NotFound is non-retryable: the fix is a spec change, not a blind retry.
576        assert!(!KopiaErrorClass::NotFound.is_retryable());
577    }
578
579    #[test]
580    fn notfound_distinguishes_uninitialized_from_missing_path() {
581        // A genuinely empty backend (format blob absent) → uninitialized: the health
582        // probe may treat this as a candidate "vanished" repository.
583        assert!(notfound_is_uninitialized(
584            "ERROR error connecting to repository: repository not initialized in the \
585             provided storage"
586        ));
587        // A missing path / unbound mount also classifies NotFound, but is a backend/
588        // mount fault — NOT an empty repository. Must NOT read as uninitialized, so the
589        // probe never misreads a mis-mounted volume as a wipe (and never nudges a recreate).
590        assert!(!notfound_is_uninitialized(
591            "open /repo/kopia.repository: no such file or directory"
592        ));
593        assert!(!notfound_is_uninitialized("stat /mnt/nas: does not exist"));
594        // Both still classify as NotFound (so first-bootstrap `create` fires for either).
595        assert_eq!(
596            KopiaErrorClass::classify("open /repo/kopia.repository: no such file or directory"),
597            KopiaErrorClass::NotFound
598        );
599    }
600
601    #[test]
602    fn classify_access_denied_and_permission_denied() {
603        // The exact RustFS/S3 message we observed live (bucket missing, masked as
604        // Access Denied) must classify as AccessDenied, not Unknown.
605        assert_eq!(
606            KopiaErrorClass::classify(
607                "can't connect to storage: error retrieving storage config from bucket \
608                 \"kopiur\": Access Denied"
609            ),
610            KopiaErrorClass::AccessDenied
611        );
612        assert_eq!(
613            KopiaErrorClass::classify("403 Forbidden"),
614            KopiaErrorClass::AccessDenied
615        );
616        // Filesystem repo path not writable by our UID → PermissionDenied, NOT
617        // the old SourceError (which marked it retryable).
618        assert_eq!(
619            KopiaErrorClass::classify("unable to create directory /repo: permission denied"),
620            KopiaErrorClass::PermissionDenied
621        );
622        assert_eq!(
623            KopiaErrorClass::classify("open /repo/kopia.repository: operation not permitted"),
624            KopiaErrorClass::PermissionDenied
625        );
626    }
627
628    #[test]
629    fn from_label_roundtrips_every_variant() {
630        for c in [
631            KopiaErrorClass::RepositoryUnavailable,
632            KopiaErrorClass::AuthFailure,
633            KopiaErrorClass::AccessDenied,
634            KopiaErrorClass::PermissionDenied,
635            KopiaErrorClass::NotFound,
636            KopiaErrorClass::Locked,
637            KopiaErrorClass::SourceError,
638            KopiaErrorClass::Unknown,
639        ] {
640            assert_eq!(KopiaErrorClass::from_label(c.as_str()), c);
641        }
642        assert_eq!(
643            KopiaErrorClass::from_label("not-a-real-class"),
644            KopiaErrorClass::Unknown
645        );
646    }
647
648    #[test]
649    fn summary_is_stable_and_volatile_free() {
650        // Every class yields a non-empty, stable summary with no per-attempt
651        // volatile content (the temp-filename suffix kopia emits in stderr must
652        // never leak into the condition message — that volatility is what caused
653        // the reconcile hot-loop).
654        for c in [
655            KopiaErrorClass::RepositoryUnavailable,
656            KopiaErrorClass::AuthFailure,
657            KopiaErrorClass::AccessDenied,
658            KopiaErrorClass::PermissionDenied,
659            KopiaErrorClass::NotFound,
660            KopiaErrorClass::Locked,
661            KopiaErrorClass::SourceError,
662            KopiaErrorClass::Unknown,
663        ] {
664            let s = c.summary();
665            assert!(!s.is_empty());
666            assert!(
667                !s.contains(".shards"),
668                "summary leaks a volatile temp path: {s}"
669            );
670            assert!(
671                !s.contains(".tmp"),
672                "summary leaks a volatile temp path: {s}"
673            );
674            // Stable across calls (it returns a 'static str, but assert intent).
675            assert_eq!(s, c.summary());
676        }
677        // The PermissionDenied summary is the actionable one for the reported bug.
678        assert!(
679            KopiaErrorClass::PermissionDenied
680                .summary()
681                .contains("not writable")
682        );
683    }
684
685    #[test]
686    fn retryable_classification() {
687        assert!(KopiaErrorClass::RepositoryUnavailable.is_retryable());
688        assert!(KopiaErrorClass::Locked.is_retryable());
689        assert!(!KopiaErrorClass::AuthFailure.is_retryable());
690        assert!(!KopiaErrorClass::AccessDenied.is_retryable());
691        assert!(!KopiaErrorClass::PermissionDenied.is_retryable());
692        assert!(!KopiaErrorClass::NotFound.is_retryable());
693        assert!(!KopiaErrorClass::Unknown.is_retryable());
694    }
695
696    #[test]
697    fn tail_keeps_last_lines() {
698        let blob: String = (0..50)
699            .map(|i| format!("line {i}\n"))
700            .collect::<Vec<_>>()
701            .join("");
702        let tail = tail_lines(&blob);
703        let kept: Vec<&str> = tail.lines().collect();
704        assert_eq!(kept.len(), STDERR_TAIL_LINES);
705        assert_eq!(*kept.last().unwrap(), "line 49");
706    }
707
708    #[test]
709    fn error_class_propagation() {
710        let e = KopiaError::NonZeroExit {
711            args: "snapshot create".into(),
712            code: Some(1),
713            class: KopiaErrorClass::Locked,
714            stderr_tail: "repository is locked".into(),
715        };
716        assert_eq!(e.class(), KopiaErrorClass::Locked);
717        assert_eq!(e.stderr_tail(), Some("repository is locked"));
718    }
719}