Skip to main content

kopiur_kopia/client/
mod.rs

1//! The `tokio::process`-based kopia client.
2//!
3//! `KopiaClient` is controller-agnostic: it knows how to invoke the `kopia`
4//! binary, stream its output, and parse the trailing JSON on stdout into the
5//! typed [`crate::model`] structs. It has **no** kube/k8s-openapi dependency
6//! (SKILL "keep it controller-agnostic").
7//!
8//! Per ADR §5.4, kopia prints progress to **stderr** and the `--json` result to
9//! **stdout**. We capture both: stdout is parsed as JSON, stderr is retained so
10//! a failure can carry the tail of the real error message.
11//!
12//! Secrets (the repository password) are passed via the environment
13//! (`KOPIA_PASSWORD`), never on argv, so they never leak into process listings
14//! or error messages.
15
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::time::Duration;
20
21use serde::de::DeserializeOwned;
22use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
23use tokio::process::Command;
24
25use crate::error::{KopiaError, KopiaErrorClass, tail_lines};
26use crate::model::{
27    IndexBlobEntry, MaintenanceInfo, RepositoryStatus, SnapshotCreateResult, SnapshotListEntry,
28    SnapshotSource,
29};
30
31/// Which maintenance pass to run.
32///
33/// `Serialize`/`Deserialize` so the mover work-spec can carry the mode as one
34/// shared type (no parallel enum in `kopiur-mover`). Wire form is the camelCase
35/// variant name (`"quick"` / `"full"`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub enum MaintenanceMode {
39    /// `kopia maintenance run --no-full` — index compaction, epoch advance.
40    Quick,
41    /// `kopia maintenance run --full` — content GC + rewrite.
42    Full,
43}
44
45/// A typed description of how to reach a kopia repository. This is the input to
46/// [`KopiaClient::repository_connect`] / [`KopiaClient::repository_create`].
47/// Externally-tagged so exactly one backend is representable (mirrors the API
48/// crate's `Backend` discipline, though this is a separate, simpler type with
49/// no kube dependency).
50///
51/// ## Credentials are NOT here
52///
53/// Secrets are supplied two ways, never on argv. Env-delivered secrets (set with
54/// [`KopiaClientBuilder::env`]) cover the backends kopia reads from the
55/// environment; file-delivered secrets are written to a file by the caller (the
56/// mover) and the *path* is passed in the relevant `ConnectSpec` field. Either
57/// way only non-secret identifiers (bucket, host, path, …) and file *paths* live
58/// in `ConnectSpec`, so a secret never leaks into a ConfigMap, a process listing,
59/// or an error message. The relevant kopia inputs by backend:
60///   * S3:    `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` (env)
61///   * Azure: `AZURE_STORAGE_KEY` / `AZURE_STORAGE_SAS_TOKEN` (env; or SP env)
62///   * B2:    `B2_KEY_ID`, `B2_KEY` (env)
63///   * WebDAV:`KOPIA_WEBDAV_USERNAME`, `KOPIA_WEBDAV_PASSWORD` (env)
64///   * GCS:   `Gcs::credentials_file` → `--credentials-file` (a JSON file path)
65///   * SFTP:  `Sftp::keyfile`/`known_hosts` → `--keyfile`/`--known-hosts` (file paths)
66///   * rclone:`Rclone::config_file` → rclone `--config` (a file path)
67///   * all:   `KOPIA_PASSWORD` (the repository encryption password; env)
68///
69/// This is the full set of kopia 0.23 `repository connect/create` backends. The
70/// operator's CRD `Backend` enum maps onto the first eight; `Gdrive`,
71/// `FromConfig`, and `Server` are exposed for client completeness (a kopia
72/// client connecting to an existing kopia API server is a legitimate backend —
73/// distinct from *running* a server, which the operator deliberately does not do).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ConnectSpec {
76    /// Filesystem backend at a local path (used in-cluster for hostPath/PVC
77    /// repos and in tests).
78    Filesystem {
79        /// Absolute path to the repository root.
80        path: PathBuf,
81    },
82    /// S3-compatible backend.
83    S3 {
84        /// Bucket name.
85        bucket: String,
86        /// Optional custom endpoint (for MinIO / non-AWS).
87        endpoint: Option<String>,
88        /// Optional key prefix within the bucket.
89        prefix: Option<String>,
90        /// Region, if required by the endpoint.
91        region: Option<String>,
92        /// Talk plain HTTP to the endpoint (`--disable-tls`). For HTTP-only
93        /// endpoints (in-cluster MinIO/RustFS); kopia otherwise assumes HTTPS.
94        disable_tls: bool,
95        /// Skip TLS certificate verification (`--disable-tls-verification`).
96        disable_tls_verification: bool,
97        /// Authenticate via the ambient AWS credential chain (IRSA web-identity,
98        /// EKS Pod Identity, IMDS) instead of static keys — the workload-identity
99        /// path. kopia 0.23 marks `--access-key`/`--secret-access-key` as
100        /// *required* flags (env-bound to `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`),
101        /// but its storage layer skips empty static credentials and falls through
102        /// minio-go's chain — so this renders the flags **explicitly empty**
103        /// (`--access-key=`), which satisfies the parser and engages the chain.
104        ambient_credentials: bool,
105    },
106    /// Azure Blob Storage backend.
107    Azure {
108        /// Blob container name.
109        container: String,
110        /// Storage account name (when not supplied via env).
111        storage_account: Option<String>,
112        /// Optional object prefix.
113        prefix: Option<String>,
114    },
115    /// Google Cloud Storage backend.
116    Gcs {
117        /// Bucket name.
118        bucket: String,
119        /// Optional object prefix.
120        prefix: Option<String>,
121        /// Path to a JSON service-account credentials file inside the mover pod
122        /// (`--credentials-file`). The mover materializes this from the
123        /// credentials Secret at runtime; `None` falls back to ambient ADC.
124        credentials_file: Option<String>,
125    },
126    /// Backblaze B2 backend.
127    B2 {
128        /// Bucket name.
129        bucket: String,
130        /// Optional object prefix.
131        prefix: Option<String>,
132    },
133    /// SFTP/SSH backend.
134    Sftp {
135        /// Server hostname.
136        host: String,
137        /// Path to the repository on the server.
138        path: String,
139        /// Server port (defaults to 22 when `None`).
140        port: Option<u16>,
141        /// SSH username.
142        username: Option<String>,
143        /// Path to a private key file inside the mover pod (`--keyfile`). The
144        /// mover materializes this from the credentials Secret at runtime.
145        keyfile: Option<String>,
146        /// Path to a `known_hosts` file inside the mover pod (`--known-hosts`),
147        /// pinning the server host key. The mover materializes this from the
148        /// credentials Secret at runtime.
149        known_hosts: Option<String>,
150    },
151    /// WebDAV backend.
152    WebDav {
153        /// WebDAV server URL.
154        url: String,
155    },
156    /// Rclone backend (shells out to an `rclone` binary).
157    Rclone {
158        /// Rclone `remote:path`.
159        remote_path: String,
160        /// Path to an `rclone.conf` inside the mover pod, forwarded to rclone via
161        /// `--rclone-args=--config=<path>`. The mover materializes this from the
162        /// config Secret at runtime; `None` uses rclone's default config lookup.
163        config_file: Option<String>,
164        /// Go-duration value for kopia's `--rclone-startup-timeout` (how long to
165        /// wait for the embedded `rclone serve` to come up). `None` leaves kopia's
166        /// default (`15s`).
167        startup_timeout: Option<String>,
168    },
169    /// Google Drive backend.
170    Gdrive {
171        /// Drive folder id that holds the repository.
172        folder_id: String,
173        /// Path to a Google service-account JSON inside the mover pod, passed as
174        /// `--credentials-file`. The mover materializes this from the credentials
175        /// Secret at runtime; `None` uses kopia's ambient credential lookup.
176        credentials_file: Option<String>,
177    },
178    /// Reconnect from a kopia configuration token/file (`repository connect
179    /// from-config`). Exactly one of `file`/`token` is meaningful.
180    FromConfig {
181        /// Path to a kopia config file.
182        file: Option<String>,
183        /// A kopia configuration token.
184        token: Option<String>,
185    },
186    /// Connect to an existing kopia API server as a client.
187    Server {
188        /// Server URL.
189        url: String,
190        /// Expected server TLS certificate fingerprint (sha256 hex).
191        fingerprint: Option<String>,
192    },
193}
194
195/// Per-connection kopia cache budgets, applied at `repository connect`/`create`
196/// time (`--content-cache-size-mb` / `--metadata-cache-size-mb`). Each mover pod
197/// connects fresh, so these size that pod's local cache. `None` leaves kopia's
198/// default. Serializable so it rides the mover work spec from controller to mover.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct CacheTuning {
202    /// `--content-cache-size-mb`: content (data) cache budget in MiB.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub content_cache_size_mb: Option<i64>,
205    /// `--metadata-cache-size-mb`: metadata cache budget in MiB.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub metadata_cache_size_mb: Option<i64>,
208}
209
210impl CacheTuning {
211    /// Whether no budgets are set (so the connect command adds no cache flags).
212    pub fn is_unset(&self) -> bool {
213        self.content_cache_size_mb.is_none() && self.metadata_cache_size_mb.is_none()
214    }
215
216    /// The `--content-cache-size-mb` / `--metadata-cache-size-mb` args for the set
217    /// budgets, in a stable order. Empty when nothing is set.
218    fn args(&self) -> Vec<String> {
219        let mut a = Vec::new();
220        if let Some(mb) = self.content_cache_size_mb {
221            a.push("--content-cache-size-mb".into());
222            a.push(mb.to_string());
223        }
224        if let Some(mb) = self.metadata_cache_size_mb {
225            a.push("--metadata-cache-size-mb".into());
226            a.push(mb.to_string());
227        }
228        a
229    }
230}
231
232impl ConnectSpec {
233    /// Stable discriminant string for logging/metrics (mirrors
234    /// `kopiur_api::backend::Backend::kind_str`).
235    ///
236    /// ```
237    /// use std::path::PathBuf;
238    /// use kopiur_kopia::ConnectSpec;
239    ///
240    /// let fs = ConnectSpec::Filesystem { path: PathBuf::from("/repo") };
241    /// assert_eq!(fs.kind_str(), "filesystem");
242    ///
243    /// let s3 = ConnectSpec::S3 {
244    ///     bucket: "backups".into(),
245    ///     endpoint: Some("https://minio.local".into()),
246    ///     prefix: None,
247    ///     region: None,
248    ///     disable_tls: false,
249    ///     disable_tls_verification: false,
250    ///     ambient_credentials: false,
251    /// };
252    /// assert_eq!(s3.kind_str(), "s3");
253    /// ```
254    pub fn kind_str(&self) -> &'static str {
255        match self {
256            ConnectSpec::Filesystem { .. } => "filesystem",
257            ConnectSpec::S3 { .. } => "s3",
258            ConnectSpec::Azure { .. } => "azure",
259            ConnectSpec::Gcs { .. } => "gcs",
260            ConnectSpec::B2 { .. } => "b2",
261            ConnectSpec::Sftp { .. } => "sftp",
262            ConnectSpec::WebDav { .. } => "webdav",
263            ConnectSpec::Rclone { .. } => "rclone",
264            ConnectSpec::Gdrive { .. } => "gdrive",
265            ConnectSpec::FromConfig { .. } => "from-config",
266            ConnectSpec::Server { .. } => "server",
267        }
268    }
269
270    /// The environment-variable names kopia reads this backend's credentials from
271    /// **directly** (no intermediate file). These are exactly the vars the
272    /// replication mover remaps from their `KOPIUR_DEST_`-prefixed copies onto their
273    /// plain names for the `sync-to` subprocess, so the destination authenticates
274    /// with its own keys instead of the source's identically named ones (issue #200).
275    ///
276    /// Exhaustive over [`ConnectSpec`] so a new backend cannot compile until its
277    /// credential-delivery mechanism is decided. File-based backends (GCS/SFTP/
278    /// Rclone/Gdrive) deliver credentials as a materialized *file* whose path is on
279    /// argv, so they read no direct credential env var and return `&[]` — the mover
280    /// stages their destination file separately. `AWS_WEB_IDENTITY_TOKEN_FILE` and
281    /// the other ambient-chain *hints* are deliberately excluded: they belong to the
282    /// pod's ServiceAccount (a workload-identity destination), not to a credential
283    /// Secret, and must never be remapped or unset.
284    pub fn direct_credential_env_names(&self) -> &'static [&'static str] {
285        match self {
286            ConnectSpec::S3 { .. } => &[
287                "AWS_ACCESS_KEY_ID",
288                "AWS_SECRET_ACCESS_KEY",
289                "AWS_SESSION_TOKEN",
290            ],
291            // Static shared key / SAS token, plus the service-principal env trio
292            // (the same names kopia's static Azure auth reads).
293            ConnectSpec::Azure { .. } => &[
294                "AZURE_STORAGE_KEY",
295                "AZURE_STORAGE_SAS_TOKEN",
296                "AZURE_TENANT_ID",
297                "AZURE_CLIENT_ID",
298                "AZURE_CLIENT_SECRET",
299            ],
300            ConnectSpec::B2 { .. } => &["B2_KEY_ID", "B2_KEY"],
301            ConnectSpec::WebDav { .. } => &["KOPIA_WEBDAV_USERNAME", "KOPIA_WEBDAV_PASSWORD"],
302            // File-delivered (materialized to a path) or credential-free.
303            ConnectSpec::Filesystem { .. }
304            | ConnectSpec::Gcs { .. }
305            | ConnectSpec::Sftp { .. }
306            | ConnectSpec::Rclone { .. }
307            | ConnectSpec::Gdrive { .. }
308            | ConnectSpec::FromConfig { .. }
309            | ConnectSpec::Server { .. } => &[],
310        }
311    }
312
313    /// The kopia subcommand args that select this backend, e.g.
314    /// `["filesystem", "--path", "/repo"]`. Used by both connect and create.
315    /// Credentials are expected in the environment, never here (see the type
316    /// docs). A new backend variant cannot compile until it is handled.
317    fn backend_args(&self) -> Vec<String> {
318        // Push `--flag value` only when the optional value is present.
319        fn opt(a: &mut Vec<String>, flag: &str, value: &Option<String>) {
320            if let Some(v) = value {
321                a.push(flag.into());
322                a.push(v.clone());
323            }
324        }
325        match self {
326            ConnectSpec::Filesystem { path } => {
327                vec![
328                    "filesystem".into(),
329                    "--path".into(),
330                    path.display().to_string(),
331                ]
332            }
333            ConnectSpec::S3 {
334                bucket,
335                endpoint,
336                prefix,
337                region,
338                disable_tls,
339                disable_tls_verification,
340                ambient_credentials,
341            } => {
342                let mut a = vec!["s3".into(), "--bucket".into(), bucket.clone()];
343                opt(&mut a, "--endpoint", endpoint);
344                opt(&mut a, "--prefix", prefix);
345                opt(&mut a, "--region", region);
346                if *disable_tls {
347                    a.push("--disable-tls".into());
348                }
349                if *disable_tls_verification {
350                    a.push("--disable-tls-verification".into());
351                }
352                if *ambient_credentials {
353                    // Single `=`-joined tokens: an empty value as a separate argv
354                    // token (`--access-key ""`) would be consumed as the flag's
355                    // value either way, but the joined form is unambiguous to
356                    // kingpin and to a human reading the Job args. Satisfies the
357                    // Required() flags with empty values so kopia's storage layer
358                    // falls through to the ambient chain (IRSA / Pod Identity / IMDS).
359                    a.push("--access-key=".into());
360                    a.push("--secret-access-key=".into());
361                }
362                a
363            }
364            ConnectSpec::Azure {
365                container,
366                storage_account,
367                prefix,
368            } => {
369                let mut a = vec!["azure".into(), "--container".into(), container.clone()];
370                opt(&mut a, "--storage-account", storage_account);
371                opt(&mut a, "--prefix", prefix);
372                a
373            }
374            ConnectSpec::Gcs {
375                bucket,
376                prefix,
377                credentials_file,
378            } => {
379                let mut a = vec!["gcs".into(), "--bucket".into(), bucket.clone()];
380                opt(&mut a, "--prefix", prefix);
381                opt(&mut a, "--credentials-file", credentials_file);
382                a
383            }
384            ConnectSpec::B2 { bucket, prefix } => {
385                let mut a = vec!["b2".into(), "--bucket".into(), bucket.clone()];
386                opt(&mut a, "--prefix", prefix);
387                a
388            }
389            ConnectSpec::Sftp {
390                host,
391                path,
392                port,
393                username,
394                keyfile,
395                known_hosts,
396            } => {
397                let mut a = vec![
398                    "sftp".into(),
399                    "--host".into(),
400                    host.clone(),
401                    "--path".into(),
402                    path.clone(),
403                ];
404                if let Some(p) = port {
405                    a.push("--port".into());
406                    a.push(p.to_string());
407                }
408                opt(&mut a, "--username", username);
409                opt(&mut a, "--keyfile", keyfile);
410                opt(&mut a, "--known-hosts", known_hosts);
411                a
412            }
413            ConnectSpec::WebDav { url } => {
414                vec!["webdav".into(), "--url".into(), url.clone()]
415            }
416            ConnectSpec::Rclone {
417                remote_path,
418                config_file,
419                startup_timeout,
420            } => {
421                let mut a = vec!["rclone".into(), "--remote-path".into(), remote_path.clone()];
422                // Forward the rclone config path to the embedded rclone. Must be a
423                // SINGLE `--rclone-args=<value>` token: kopia's CLI parser treats a
424                // separate value starting with `--` as the next flag, so
425                // `--rclone-args --config=…` fails with "expected argument".
426                if let Some(cfg) = config_file {
427                    a.push(format!("--rclone-args=--config={cfg}"));
428                }
429                // kopia's own connect flag (not an rclone arg): how long to wait
430                // for the embedded `rclone serve` before failing the connect.
431                if let Some(t) = startup_timeout {
432                    a.push(format!("--rclone-startup-timeout={t}"));
433                }
434                a
435            }
436            ConnectSpec::Gdrive {
437                folder_id,
438                credentials_file,
439            } => {
440                let mut a = vec!["gdrive".into(), "--folder-id".into(), folder_id.clone()];
441                opt(&mut a, "--credentials-file", credentials_file);
442                a
443            }
444            ConnectSpec::FromConfig { file, token } => {
445                let mut a = vec!["from-config".into()];
446                opt(&mut a, "--file", file);
447                opt(&mut a, "--token", token);
448                a
449            }
450            ConnectSpec::Server { url, fingerprint } => {
451                let mut a = vec!["server".into(), "--url".into(), url.clone()];
452                opt(&mut a, "--server-cert-fingerprint", fingerprint);
453                a
454            }
455        }
456    }
457}
458
459/// Options for `kopia snapshot verify`. All fields default to kopia's defaults
460/// when `None`/empty.
461#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct VerifyOptions {
463    /// `--sources`: restrict verification to these kopia sources
464    /// (`username@hostname:path`). Empty (the default) verifies EVERY snapshot
465    /// in the repository — kopia's own default. For a per-`SnapshotPolicy`
466    /// verify against a shared repository that is both wrong (it re-verifies
467    /// every other policy's data under a different identity) and expensive
468    /// (`verifyFilesPercent` then samples the WHOLE repository, not just this
469    /// policy's snapshots — issue #250), so the controller always scopes a
470    /// quick verify to the policy's resolved identity.
471    pub sources: Vec<String>,
472    /// `--verify-files-percent`: randomly fully-read this percentage of files.
473    pub verify_files_percent: Option<u8>,
474    /// `--max-errors`: stop after this many errors (0 = never stop early).
475    pub max_errors: Option<u32>,
476    /// `--parallel`: verification parallelism (kopia default: 8).
477    pub parallel: Option<u32>,
478    /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
479    pub file_parallelism: Option<u32>,
480    /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
481    pub file_queue_length: Option<u32>,
482}
483
484/// Options for `kopia snapshot create` (M4 flag sweep, issue #216 category
485/// sweep). All-default reproduces kopia's own defaults / today's argv:
486/// `fail_fast: None` (kopia default: keep going past per-file errors, subject
487/// to the `errorHandling.ignore*Errors` policy knobs), `upload_limit_mb: None`
488/// (kopia default: unlimited), `description: None` (kopia default: empty).
489#[derive(Debug, Clone, Default, PartialEq, Eq)]
490pub struct SnapshotCreateOptions {
491    /// `--[no-]fail-fast`: abort the snapshot at the first error instead of
492    /// collecting and continuing (kopia default: false — collect and continue).
493    pub fail_fast: Option<bool>,
494    /// `--upload-limit-mb`: abort the snapshot once this many MB have been
495    /// uploaded (kopia default: 0 — unlimited).
496    pub upload_limit_mb: Option<i64>,
497    /// `--description`: free-form text recorded on the snapshot manifest
498    /// (kopia default: empty).
499    pub description: Option<String>,
500}
501
502/// Options for `kopia restore` / `kopia snapshot restore`. The tri-state
503/// booleans map to kopia's `--[no-]flag` form: `Some(true)` → `--flag`,
504/// `Some(false)` → `--no-flag`, `None` → omit (kopia default). M2 flag sweep
505/// (issue #216 gap analysis) added everything below `overwrite_files`; all of
506/// them, plus `delete_extra`, were previously either absent or dormant (the
507/// mover's `RestoreOp::restore_options()` dropped them via `..Default::default()`).
508#[derive(Debug, Clone, Default, PartialEq, Eq)]
509pub struct RestoreOptions {
510    /// `--[no-]ignore-permission-errors` (kopia default: true).
511    pub ignore_permission_errors: Option<bool>,
512    /// `--[no-]write-files-atomically`.
513    pub write_files_atomically: Option<bool>,
514    /// `--[no-]overwrite-files` (kopia default: true).
515    pub overwrite_files: Option<bool>,
516    /// `--[no-]overwrite-directories` (kopia default: true).
517    pub overwrite_directories: Option<bool>,
518    /// `--[no-]overwrite-symlinks` (kopia default: true).
519    pub overwrite_symlinks: Option<bool>,
520    /// `--[no-]write-sparse-files` (kopia default: false).
521    pub write_sparse_files: Option<bool>,
522    /// `--[no-]skip-owners` (kopia default: false).
523    pub skip_owners: Option<bool>,
524    /// `--[no-]skip-permissions` (kopia default: false).
525    pub skip_permissions: Option<bool>,
526    /// `--[no-]skip-times` (kopia default: false).
527    pub skip_times: Option<bool>,
528    /// `--[no-]ignore-errors` (kopia default: false).
529    pub ignore_errors: Option<bool>,
530    /// `--[no-]skip-existing`: skip files/symlinks that already exist in the
531    /// target (kopia default: false). A genuine kingpin tri-state, not a
532    /// presence-only flag — widened from a bare `bool`.
533    pub skip_existing: Option<bool>,
534    /// `--[no-]delete-extra`: delete files/directories/symlinks present in the
535    /// restore path but absent from the snapshot (kopia default: false). Backs
536    /// `Restore.spec.options.enableFileDeletion`, which was previously a silent
537    /// no-op — this struct had no field for it at all.
538    pub delete_extra: Option<bool>,
539    /// `--parallel`: restore parallelism (1 disables).
540    pub parallel: Option<u32>,
541}
542
543/// Options for `kopia repository sync-to` (ADR-0005 §13(d) / issue #216). Every
544/// field's `None`/`false` reproduces kopia's own default — an all-`None`,
545/// `delete_extra: false` instance yields the exact same argv `sync_to_args`
546/// produced before this struct existed. The tri-state booleans map to kopia's
547/// `--[no-]flag` grammar, same as [`RestoreOptions`]: `Some(true)` → `--flag`,
548/// `Some(false)` → `--no-flag`, `None` → omit (kopia default).
549#[derive(Debug, Clone, Default, PartialEq, Eq)]
550pub struct SyncToOptions {
551    /// `--parallel`: copy parallelism to the destination (kopia default `1` —
552    /// sequential; the root cause of #216's multi-week initial-seed times).
553    pub parallel: Option<u32>,
554    /// `--delete`: prune destination-only blobs for a true mirror (kopia
555    /// default `false` — additive sync, never removes destination content).
556    pub delete_extra: bool,
557    /// `--[no-]must-exist`: fail instead of initializing the destination's
558    /// repository-format blob (kopia default `false`).
559    pub must_exist: Option<bool>,
560    /// `--[no-]times`: synchronize blob modification times to the destination,
561    /// when supported (kopia default `true`).
562    pub times: Option<bool>,
563    /// `--[no-]update`: update blobs already present at the destination when
564    /// the source copy is newer (kopia default `true`).
565    pub update: Option<bool>,
566    /// `--max-download-speed`: cap read throughput from the source, bytes/sec
567    /// (kopia default: unlimited).
568    pub max_download_speed_bytes_per_second: Option<i64>,
569    /// `--max-upload-speed`: cap write throughput to the destination, bytes/sec
570    /// (kopia default: unlimited).
571    pub max_upload_speed_bytes_per_second: Option<i64>,
572}
573
574/// Policy fields kopia applies via `kopia policy set`. Mirrors the operator's
575/// `SnapshotPolicy.spec.policy` without depending on the api crate, so the kopia
576/// crate stays controller-agnostic. The caller translates the CRD policy into
577/// this and the controller applies it before the first snapshot.
578#[derive(Debug, Clone, Default, PartialEq, Eq)]
579pub struct PolicyArgs {
580    /// `--compression` algorithm (e.g. `zstd`, `none`).
581    pub compression: Option<String>,
582    /// `--splitter` algorithm.
583    pub splitter: Option<String>,
584    /// `--add-ignore` glob patterns.
585    pub ignore: Vec<String>,
586    /// `--add-never-compress` glob patterns.
587    pub never_compress: Vec<String>,
588    /// `--ignore-cache-dirs` tri-state (honor `CACHEDIR.TAG`). `None` leaves kopia's default.
589    pub ignore_cache_dirs: Option<bool>,
590    /// Backup-side error handling (`--ignore-file-errors`) tri-state. ADR-0005 §13(b).
591    pub ignore_file_errors: Option<bool>,
592    /// `--ignore-dir-errors` tri-state. ADR-0005 §13(b).
593    pub ignore_dir_errors: Option<bool>,
594    /// `--ignore-unknown-types` tri-state. ADR-0005 §13(b).
595    pub ignore_unknown_types: Option<bool>,
596    /// `--max-parallel-snapshots` upload parallelism. ADR-0005 §13(f).
597    pub max_parallel_snapshots: Option<u32>,
598    /// `--max-parallel-file-reads` upload parallelism. ADR-0005 §13(f).
599    pub max_parallel_file_reads: Option<u32>,
600    /// `--keep-latest`: most-recent-N backups to keep per source.
601    ///
602    /// This field (and its five siblings below) exists ONLY so the mover can
603    /// pin kopia's own create-time retention to effectively-infinite at the
604    /// identity scope — see `kopiur_mover::workspec::KOPIA_KEEP_MAX`'s doc
605    /// comment for the full hazard. There is deliberately no CRD/workspec
606    /// surface that lets a user set these: kopia-side retention stays
607    /// forbidden (`crates/api/src/error.rs`'s `InlineRetentionForbidden`),
608    /// and `PolicyArgsSpec::to_kopia` never populates them.
609    pub keep_latest: Option<i64>,
610    /// `--keep-hourly`. See [`Self::keep_latest`].
611    pub keep_hourly: Option<i64>,
612    /// `--keep-daily`. See [`Self::keep_latest`].
613    pub keep_daily: Option<i64>,
614    /// `--keep-weekly`. See [`Self::keep_latest`].
615    pub keep_weekly: Option<i64>,
616    /// `--keep-monthly`. See [`Self::keep_latest`].
617    pub keep_monthly: Option<i64>,
618    /// `--keep-annual`. See [`Self::keep_latest`].
619    pub keep_annual: Option<i64>,
620    /// Verbatim extra `policy set` flags (the CRD escape hatch).
621    pub extra_args: Vec<String>,
622}
623
624/// Create-time-fixed repository options applied at `kopia repository create`
625/// (ADR-0005 §13(a)): the encryption/splitter/hash algorithms baked into the repo
626/// format, plus optional Reed-Solomon ECC parity guarding blobs against backend
627/// bit-rot. All fields are immutable post-create (webhook-enforced, §7); kopia only
628/// honors them at create time. Pure args builder so it's unit-testable.
629#[derive(Debug, Clone, Default, PartialEq, Eq)]
630pub struct CreateOptions {
631    /// `--encryption` algorithm (e.g. `AES256-GCM-HMAC-SHA256`).
632    pub encryption: Option<String>,
633    /// `--object-splitter` algorithm.
634    pub splitter: Option<String>,
635    /// `--block-hash` content-hash algorithm.
636    pub hash: Option<String>,
637    /// `--ecc` Reed-Solomon algorithm (e.g. `REED-SOLOMON-CRC32`). ADR-0005 §13(a).
638    pub ecc: Option<String>,
639    /// `--ecc-overhead-percent` parity overhead. ADR-0005 §13(a).
640    pub ecc_overhead_percent: Option<i64>,
641}
642
643impl CreateOptions {
644    /// The create-time `--encryption`/`--object-splitter`/`--block-hash`/`--ecc`/
645    /// `--ecc-overhead-percent` args, in a stable order. Empty when nothing is set.
646    pub fn args(&self) -> Vec<String> {
647        let mut a = Vec::new();
648        if let Some(v) = &self.encryption {
649            a.push("--encryption".into());
650            a.push(v.clone());
651        }
652        if let Some(v) = &self.splitter {
653            a.push("--object-splitter".into());
654            a.push(v.clone());
655        }
656        if let Some(v) = &self.hash {
657            a.push("--block-hash".into());
658            a.push(v.clone());
659        }
660        if let Some(v) = &self.ecc {
661            a.push("--ecc".into());
662            a.push(v.clone());
663        }
664        if let Some(p) = self.ecc_overhead_percent {
665            a.push("--ecc-overhead-percent".into());
666            a.push(p.to_string());
667        }
668        a
669    }
670}
671
672/// Repository throttling limits applied via `kopia repository throttle set`
673/// (ADR-0005 §13(e)). Each `None` leaves kopia's current value untouched. Pure args
674/// builder so it's unit-testable; an all-`None` instance yields no flags.
675#[derive(Debug, Clone, Default, PartialEq, Eq)]
676pub struct ThrottleArgs {
677    /// `--upload-bytes-per-second`.
678    pub upload_bytes_per_second: Option<i64>,
679    /// `--download-bytes-per-second`.
680    pub download_bytes_per_second: Option<i64>,
681    /// `--read-requests-per-second`.
682    pub read_ops_per_second: Option<i64>,
683    /// `--write-requests-per-second`.
684    pub write_ops_per_second: Option<i64>,
685}
686
687impl ThrottleArgs {
688    /// The `--*-per-second` flags for the set limits, in a stable order. Empty when
689    /// nothing is set (the caller then skips the `throttle set` invocation).
690    pub fn args(&self) -> Vec<String> {
691        let mut a = Vec::new();
692        if let Some(v) = self.upload_bytes_per_second {
693            a.push("--upload-bytes-per-second".into());
694            a.push(v.to_string());
695        }
696        if let Some(v) = self.download_bytes_per_second {
697            a.push("--download-bytes-per-second".into());
698            a.push(v.to_string());
699        }
700        if let Some(v) = self.read_ops_per_second {
701            a.push("--read-requests-per-second".into());
702            a.push(v.to_string());
703        }
704        if let Some(v) = self.write_ops_per_second {
705            a.push("--write-requests-per-second".into());
706            a.push(v.to_string());
707        }
708        a
709    }
710
711    /// Whether no limits are set (so `throttle set` is skipped).
712    pub fn is_empty(&self) -> bool {
713        self.args().is_empty()
714    }
715}
716
717/// Flags for `kopia repository set-parameters`. Modeled on [`ThrottleArgs`]: an all-`None`
718/// builder whose caller skips the invocation entirely when nothing is set.
719///
720/// Durations are pre-rendered **strings** with a unit, not numbers — kopia's
721/// `time.ParseDuration` rejects a bare number (`--epoch-min-duration=3600` →
722/// `time: missing unit in duration "3600"`), so the caller must render them
723/// (`kopiur_api::render_go_duration`) rather than pass user text through.
724#[derive(Debug, Clone, Default, PartialEq, Eq)]
725pub struct SetParametersArgs {
726    /// `--epoch-min-duration` (e.g. `"6h"`).
727    pub epoch_min_duration: Option<String>,
728    /// `--epoch-refresh-frequency` (e.g. `"20m"`).
729    pub epoch_refresh_frequency: Option<String>,
730    /// `--epoch-advance-on-count`.
731    pub epoch_advance_on_count: Option<i64>,
732    /// `--epoch-advance-on-size-mb`. **MiB**, despite the flag name — kopia multiplies by
733    /// 1048576.
734    pub epoch_advance_on_size_mb: Option<i64>,
735    /// `--epoch-checkpoint-frequency`.
736    pub epoch_checkpoint_frequency: Option<i64>,
737    /// `--epoch-delete-parallelism`.
738    pub epoch_delete_parallelism: Option<i64>,
739}
740
741impl SetParametersArgs {
742    /// The flags for the set parameters, in a stable order. Empty when nothing is set (the
743    /// caller then skips the `set-parameters` invocation).
744    pub fn args(&self) -> Vec<String> {
745        let mut a = Vec::new();
746        if let Some(v) = &self.epoch_min_duration {
747            a.push("--epoch-min-duration".into());
748            a.push(v.clone());
749        }
750        if let Some(v) = &self.epoch_refresh_frequency {
751            a.push("--epoch-refresh-frequency".into());
752            a.push(v.clone());
753        }
754        if let Some(v) = self.epoch_advance_on_count {
755            a.push("--epoch-advance-on-count".into());
756            a.push(v.to_string());
757        }
758        if let Some(v) = self.epoch_advance_on_size_mb {
759            a.push("--epoch-advance-on-size-mb".into());
760            a.push(v.to_string());
761        }
762        if let Some(v) = self.epoch_checkpoint_frequency {
763            a.push("--epoch-checkpoint-frequency".into());
764            a.push(v.to_string());
765        }
766        if let Some(v) = self.epoch_delete_parallelism {
767            a.push("--epoch-delete-parallelism".into());
768            a.push(v.to_string());
769        }
770        a
771    }
772
773    /// Whether no parameters are set (so `set-parameters` is skipped).
774    pub fn is_empty(&self) -> bool {
775        self.args().is_empty()
776    }
777}
778
779/// UI authentication mode for `kopia server start`. Controller-agnostic mirror of
780/// the api crate's `ServerAuth` (this crate has no kube dependency).
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub enum ServerAuthMode {
783    /// Require a UI login. `username` goes on argv; the password is supplied
784    /// separately to [`KopiaClient::server_start`] (it is never baked into the pure
785    /// arg builder, nor into a ConfigMap).
786    Password {
787        /// HTTP basic-auth username for the UI (`--server-username`).
788        username: String,
789    },
790    /// No UI authentication (`--without-password`). kopia requires `--insecure`
791    /// alongside it, which [`server_start_args`] always emits.
792    None,
793}
794
795impl ServerAuthMode {
796    /// Stable discriminant for logging.
797    pub fn kind_str(&self) -> &'static str {
798        match self {
799            ServerAuthMode::Password { .. } => "password",
800            ServerAuthMode::None => "none",
801        }
802    }
803}
804
805/// A typed description of how to run `kopia server start` (the web UI).
806///
807/// ## Why this is its own non-returning path (not `run_ok`)
808///
809/// `server start` is a long-running process that never exits on success, so the
810/// `run_ok`/`run_json` "spawn, read to EOF, wait for exit code" pattern would hang
811/// forever. [`KopiaClient::server_start`] instead `exec`s the binary so kopia takes
812/// over this PID and receives `SIGTERM` directly from the kubelet on pod shutdown.
813///
814/// ## TLS and auth
815///
816/// The server always runs with `--insecure` (no in-pod TLS): TLS is terminated by
817/// the user's ingress and the Service speaks plain HTTP. `--insecure` is kopia's
818/// *no-TLS* switch — it is required in every mode, and is **not** the no-auth knob
819/// (that is `--without-password`, selected by [`ServerAuthMode::None`]).
820#[derive(Debug, Clone, PartialEq, Eq)]
821pub struct ServerStartSpec {
822    /// Listen address — must be non-loopback (e.g. `0.0.0.0:51515`) to be reachable
823    /// through a Service.
824    pub address: String,
825    /// UI authentication mode.
826    pub auth: ServerAuthMode,
827    /// Serve the embedded HTML UI (`--ui`). Defaults to enabled.
828    pub ui: bool,
829}
830
831impl Default for ServerStartSpec {
832    fn default() -> Self {
833        Self {
834            address: "0.0.0.0:51515".to_string(),
835            auth: ServerAuthMode::None,
836            ui: true,
837        }
838    }
839}
840
841/// Builder for [`KopiaClient`].
842#[derive(Debug, Clone, Default)]
843pub struct KopiaClientBuilder {
844    binary: Option<PathBuf>,
845    common_env: BTreeMap<String, String>,
846    common_args: Vec<String>,
847    default_timeout: Option<Duration>,
848}
849
850impl KopiaClientBuilder {
851    /// Set the path to the kopia binary. Injectable so tests can point at a
852    /// fake shim. Defaults to `kopia` (resolved via `PATH`).
853    pub fn binary(mut self, binary: impl Into<PathBuf>) -> Self {
854        self.binary = Some(binary.into());
855        self
856    }
857
858    /// Add an environment variable applied to every invocation. Use this for
859    /// `KOPIA_PASSWORD`, `KOPIA_CONFIG_PATH`, cache dirs, and S3 credentials.
860    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
861        self.common_env.insert(key.into(), value.into());
862        self
863    }
864
865    /// Add a global arg applied (after the subcommand tokens) to every
866    /// invocation. Must be a flag kopia accepts on *every* subcommand (e.g. a
867    /// global flag); per-subcommand flags belong on the specific method.
868    /// Prefer env vars (e.g. `KOPIA_CHECK_FOR_UPDATES=false`) for cross-cutting
869    /// behavior.
870    pub fn common_arg(mut self, arg: impl Into<String>) -> Self {
871        self.common_args.push(arg.into());
872        self
873    }
874
875    /// Default per-invocation timeout. `None` means no timeout.
876    pub fn default_timeout(mut self, timeout: Duration) -> Self {
877        self.default_timeout = Some(timeout);
878        self
879    }
880
881    /// Finalize.
882    pub fn build(self) -> KopiaClient {
883        let mut common_args = self.common_args;
884        // kopia's hidden default-on `--auto-maintenance` opportunistically runs
885        // a maintenance pass as a side effect of other commands (`snapshot
886        // create`/`delete`/`expire`, and — verified against the pinned kopia
887        // 0.23.1 binary — even a bare `policy set`) whenever the connected
888        // client identity equals the repository's designated maintenance
889        // owner. Only the Maintenance CR's own `maintenance run` may trigger
890        // maintenance (that explicit subcommand is unaffected by this flag —
891        // also verified against the pinned binary), so every `KopiaClient`
892        // carries `--no-auto-maintenance` on every invocation, unconditionally,
893        // rather than relying on each call site to remember it.
894        common_args.push("--no-auto-maintenance".into());
895        KopiaClient {
896            binary: self.binary.unwrap_or_else(|| PathBuf::from("kopia")),
897            common_env: self.common_env,
898            common_args,
899            default_timeout: self.default_timeout,
900        }
901    }
902}
903
904/// A kopia client backed by the real `kopia` binary via `tokio::process`.
905///
906/// Construction is pure — building a client never spawns a process. Only the
907/// `async` methods invoke `kopia`. The builder defaults the binary to `kopia`
908/// (resolved via `PATH`); inject a path for tests or non-standard images:
909///
910/// ```
911/// use std::path::PathBuf;
912/// use kopiur_kopia::KopiaClient;
913///
914/// let client = KopiaClient::builder().build();
915/// assert_eq!(client.binary(), &PathBuf::from("kopia"));
916///
917/// let custom = KopiaClient::builder()
918///     .binary("/usr/local/bin/kopia")
919///     .env("KOPIA_PASSWORD", "s3cr3t")
920///     .build();
921/// assert_eq!(custom.binary(), &PathBuf::from("/usr/local/bin/kopia"));
922/// ```
923#[derive(Debug, Clone)]
924pub struct KopiaClient {
925    binary: PathBuf,
926    common_env: BTreeMap<String, String>,
927    common_args: Vec<String>,
928    default_timeout: Option<Duration>,
929}
930
931/// SIGKILL a timed-out kopia child AND reap it before returning
932/// (`Child::kill()` = `start_kill` + `wait`). Without the wait the killed
933/// child lingers as a zombie until tokio's SIGCHLD-driven orphan reaper gets
934/// to it — best-effort and non-deterministic; with the controller's 120s
935/// `default_timeout`, a hung backend would leave one transient zombie per
936/// retry. Reaping inline makes cleanup a guarantee instead of a race.
937/// Best-effort on error: nothing here can improve on the Timeout being
938/// returned, so a kill/wait failure is only logged.
939async fn kill_and_reap(child: &mut tokio::process::Child) {
940    if let Err(e) = child.kill().await {
941        tracing::debug!(error = %e, "could not kill/reap a timed-out kopia child");
942    }
943}
944
945/// The raw outcome of running a kopia subprocess.
946struct RawOutput {
947    code: Option<i32>,
948    stdout: String,
949    stderr: String,
950}
951
952impl KopiaClient {
953    /// Start building a client.
954    pub fn builder() -> KopiaClientBuilder {
955        KopiaClientBuilder::default()
956    }
957
958    /// The configured binary path (useful for diagnostics / tests).
959    pub fn binary(&self) -> &PathBuf {
960        &self.binary
961    }
962
963    /// The environment applied to every invocation (useful for tests asserting
964    /// that the cache/log/config dirs were injected).
965    pub fn common_env(&self) -> &BTreeMap<String, String> {
966        &self.common_env
967    }
968
969    /// The global args appended after the subcommand on every invocation
970    /// (useful for tests asserting e.g. `--no-auto-maintenance` is always
971    /// present).
972    pub fn common_args(&self) -> &[String] {
973        &self.common_args
974    }
975
976    /// The timeout applied to every invocation when set (useful for tests
977    /// asserting a caller time-bounds its subprocesses).
978    pub fn default_timeout(&self) -> Option<Duration> {
979        self.default_timeout
980    }
981
982    /// Run kopia with the given subcommand args, returning raw output. Applies
983    /// `common_env` and inserts `common_args` immediately after the subcommand,
984    /// plus a per-invocation environment overlay (`Some(value)` sets a variable,
985    /// `None` **unsets** an otherwise-inherited one — pass an empty map for the
986    /// common case). stdout and stderr are fully captured. Honors the default
987    /// timeout if set. Used by the replication mover
988    /// to point `kopia repository sync-to` at the *destination* backend's
989    /// credentials (remapped from their `KOPIUR_DEST_`-prefixed copies) while
990    /// clearing any source credential the destination does not set, so a stale
991    /// source `AWS_SESSION_TOKEN` (etc.) cannot leak into the destination auth.
992    async fn run_with_env(
993        &self,
994        args: &[String],
995        env_overlay: &BTreeMap<String, Option<String>>,
996    ) -> Result<RawOutput, KopiaError> {
997        let display_args = args.join(" ");
998        let mut cmd = Command::new(&self.binary);
999        // Do not inherit the ambient environment's KOPIA_* unless the caller
1000        // set it explicitly; but we *do* inherit PATH etc. by default, which is
1001        // fine. We only override what common_env specifies.
1002        for (k, v) in &self.common_env {
1003            cmd.env(k, v);
1004        }
1005        // Per-invocation overlay wins over both the inherited env and common_env.
1006        for (k, v) in env_overlay {
1007            match v {
1008                Some(value) => cmd.env(k, value),
1009                None => cmd.env_remove(k),
1010            };
1011        }
1012        cmd.args(args);
1013        // Append common args (e.g. --no-check-for-updates) after the subcommand
1014        // tokens the caller passed.
1015        cmd.args(&self.common_args);
1016        cmd.stdin(Stdio::null());
1017        cmd.stdout(Stdio::piped());
1018        cmd.stderr(Stdio::piped());
1019
1020        // Spawn with a bounded retry on transient errnos. ETXTBSY (26) and
1021        // EAGAIN (11) are not "the binary is wrong" failures — they're transient
1022        // races: ETXTBSY appears when another thread in a multithreaded process
1023        // forks-for-exec while the target file still has a writable fd open
1024        // elsewhere (the classic fork/exec race), and EAGAIN appears under fork
1025        // pressure on a busy node. A real bad-binary error (ENOENT, EACCES) is
1026        // returned immediately. Retries are quick and capped.
1027        let mut child = {
1028            let mut attempt = 0u32;
1029            loop {
1030                match cmd.spawn() {
1031                    Ok(c) => break c,
1032                    Err(e) if matches!(e.raw_os_error(), Some(26) | Some(11)) && attempt < 10 => {
1033                        attempt += 1;
1034                        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1035                    }
1036                    Err(source) => {
1037                        return Err(KopiaError::Spawn {
1038                            binary: self.binary.display().to_string(),
1039                            source,
1040                        });
1041                    }
1042                }
1043            }
1044        };
1045
1046        // Take the pipes so we can read both concurrently without deadlocking
1047        // on a full pipe buffer.
1048        let mut stdout_pipe = child.stdout.take().expect("stdout piped");
1049        let stderr_pipe = child.stderr.take().expect("stderr piped");
1050
1051        let read_out = async {
1052            let mut buf = String::new();
1053            stdout_pipe.read_to_string(&mut buf).await.map(|_| buf)
1054        };
1055        let read_err = async {
1056            // Stream kopia's stderr line-by-line so its real progress and log
1057            // output is visible in `kubectl logs` (at debug, target `kopia`) for
1058            // both the controller's short ops and the long-running mover Job —
1059            // while still accumulating the full text byte-for-byte for the
1060            // failure tail carried by `KopiaError::NonZeroExit`.
1061            let mut reader = BufReader::new(stderr_pipe);
1062            let mut buf = String::new();
1063            let mut line = String::new();
1064            loop {
1065                line.clear();
1066                match reader.read_line(&mut line).await {
1067                    Ok(0) => break,
1068                    Ok(_) => {
1069                        let trimmed = line.trim_end_matches(['\n', '\r']);
1070                        if !trimmed.is_empty() {
1071                            tracing::debug!(target: "kopia", "{trimmed}");
1072                        }
1073                        buf.push_str(&line);
1074                    }
1075                    Err(e) => return Err(e),
1076                }
1077            }
1078            Ok(buf)
1079        };
1080
1081        let wait_with_io = async {
1082            let (out, err, status) = tokio::join!(read_out, read_err, child.wait());
1083            Ok::<_, std::io::Error>((out?, err?, status?))
1084        };
1085
1086        let (stdout, stderr, status) = match self.default_timeout {
1087            Some(t) => match tokio::time::timeout(t, wait_with_io).await {
1088                Ok(res) => res.map_err(|source| KopiaError::Spawn {
1089                    binary: self.binary.display().to_string(),
1090                    source,
1091                })?,
1092                Err(_) => {
1093                    kill_and_reap(&mut child).await;
1094                    return Err(KopiaError::Timeout {
1095                        args: display_args,
1096                        seconds: t.as_secs(),
1097                    });
1098                }
1099            },
1100            None => wait_with_io.await.map_err(|source| KopiaError::Spawn {
1101                binary: self.binary.display().to_string(),
1102                source,
1103            })?,
1104        };
1105
1106        Ok(RawOutput {
1107            code: status.code(),
1108            stdout,
1109            stderr,
1110        })
1111    }
1112
1113    /// Run kopia and require a zero exit code, returning stdout. On a non-zero
1114    /// exit, builds a structured [`KopiaError::NonZeroExit`] with the stderr
1115    /// tail and a best-effort error class.
1116    async fn run_ok(&self, args: &[String]) -> Result<String, KopiaError> {
1117        self.run_ok_with_env(args, &BTreeMap::new()).await
1118    }
1119
1120    /// [`Self::run_ok`] with a per-invocation environment overlay (see
1121    /// [`Self::run_with_env`]).
1122    async fn run_ok_with_env(
1123        &self,
1124        args: &[String],
1125        env_overlay: &BTreeMap<String, Option<String>>,
1126    ) -> Result<String, KopiaError> {
1127        let out = self.run_with_env(args, env_overlay).await?;
1128        if out.code == Some(0) {
1129            Ok(out.stdout)
1130        } else {
1131            Err(KopiaError::NonZeroExit {
1132                args: args.join(" "),
1133                code: out.code,
1134                class: KopiaErrorClass::classify(&out.stderr),
1135                stderr_tail: tail_lines(&out.stderr),
1136            })
1137        }
1138    }
1139
1140    /// Run kopia, require success, and parse the trailing JSON value on stdout
1141    /// into `T`. Kopia prints the result as the *last* JSON value on stdout
1142    /// (progress goes to stderr), so we parse from the first `{`/`[`.
1143    async fn run_json<T: DeserializeOwned>(
1144        &self,
1145        args: &[String],
1146        context: &str,
1147    ) -> Result<T, KopiaError> {
1148        let stdout = self.run_ok(args).await?;
1149        let json = extract_json(&stdout).ok_or_else(|| KopiaError::EmptyOutput {
1150            context: context.to_string(),
1151        })?;
1152        serde_json::from_str::<T>(json).map_err(|source| KopiaError::Json {
1153            context: context.to_string(),
1154            source,
1155        })
1156    }
1157
1158    /// Connect to an existing repository (`kopia repository connect <backend>`).
1159    /// `cache` sizes this connection's local kopia cache; pass
1160    /// [`CacheTuning::default`] to leave kopia's defaults.
1161    pub async fn repository_connect(
1162        &self,
1163        spec: &ConnectSpec,
1164        cache: CacheTuning,
1165    ) -> Result<(), KopiaError> {
1166        self.run_ok(&connect_args(spec, cache, false))
1167            .await
1168            .map(|_| ())
1169    }
1170
1171    /// Connect to an existing repository **read-only** (`kopia repository
1172    /// connect <backend> --readonly`). The read-only bit persists in kopia's
1173    /// client config, so every subsequent invocation on this connection is
1174    /// structurally unable to mutate the repository — the connect mode for
1175    /// browse sessions. Every other mover flow stays on the read-write
1176    /// [`Self::repository_connect`].
1177    pub async fn repository_connect_readonly(
1178        &self,
1179        spec: &ConnectSpec,
1180        cache: CacheTuning,
1181    ) -> Result<(), KopiaError> {
1182        self.run_ok(&connect_args(spec, cache, true))
1183            .await
1184            .map(|_| ())
1185    }
1186
1187    /// Create a new repository (`kopia repository create <backend>`). `cache` sizes
1188    /// the creating connection's local cache; pass [`CacheTuning::default`] to leave
1189    /// kopia's defaults. `create_opts` carries the create-time-fixed knobs
1190    /// (encryption/splitter/hash algorithms, ECC) baked into the repository format.
1191    pub async fn repository_create(
1192        &self,
1193        spec: &ConnectSpec,
1194        cache: CacheTuning,
1195        create_opts: &CreateOptions,
1196    ) -> Result<(), KopiaError> {
1197        let mut args = vec!["repository".into(), "create".into()];
1198        args.extend(spec.backend_args());
1199        args.extend(cache.args());
1200        args.extend(create_opts.args());
1201        self.run_ok(&args).await.map(|_| ())
1202    }
1203
1204    /// Set the repository's throttling limits (`kopia repository throttle set`).
1205    /// Caps upload/download bytes-per-sec and read/list/upload ops-per-sec so a run
1206    /// doesn't saturate a link or hammer an object store (ADR-0005 §13(e)). A no-op
1207    /// (skips the call) when nothing is set.
1208    pub async fn repository_throttle_set(&self, throttle: &ThrottleArgs) -> Result<(), KopiaError> {
1209        let flags = throttle.args();
1210        if flags.is_empty() {
1211            return Ok(());
1212        }
1213        let mut args = vec!["repository".into(), "throttle".into(), "set".into()];
1214        args.extend(flags);
1215        self.run_ok(&args).await.map(|_| ())
1216    }
1217
1218    /// Rewrite mutable repository parameters on the CONNECTED repository
1219    /// (`kopia repository set-parameters [flags]`), issue #258. No-op when nothing is set.
1220    ///
1221    /// Two properties the caller must respect:
1222    ///
1223    /// - **Never on a read-only connection.** kopia hard-errors (`unable to write blobcfg
1224    ///   blob: PutBlob() failed for "kopia.blobcfg": storage is read-only`), so a
1225    ///   `mode: ReadOnly` repository must not reach here.
1226    /// - **This invalidates every other client's cached format blob.** kopia says so itself
1227    ///   ("you must disconnect and re-connect all other Kopia clients") and drops the local
1228    ///   `kopia.repository`/`kopia.blobcfg` cache. Other clients re-read within
1229    ///   `formatBlobCacheDuration` (15m) on their own, but this is why the caller applies
1230    ///   only on observed drift rather than unconditionally.
1231    ///
1232    /// Needs no maintenance ownership/lease.
1233    pub async fn repository_set_parameters(
1234        &self,
1235        params: &SetParametersArgs,
1236    ) -> Result<(), KopiaError> {
1237        let flags = params.args();
1238        if flags.is_empty() {
1239            return Ok(());
1240        }
1241        let mut args = vec!["repository".into(), "set-parameters".into()];
1242        args.extend(flags);
1243        self.run_ok(&args).await.map(|_| ())
1244    }
1245
1246    /// Mirror the *connected* repository's blobs to a destination backend
1247    /// (`kopia repository sync-to <destination> [flags]`), ADR-0005 §13(d) / issue
1248    /// #216. The caller must already be connected to the **source** repository;
1249    /// this copies its blobs to `destination`. The destination's backend args are
1250    /// built by `ConnectSpec::backend_args` (the same builder connect/create use),
1251    /// so a new backend variant is wired through automatically. `opts` carries the
1252    /// tuning knobs (parallelism, `--delete`, the must-exist/times/update
1253    /// tri-states, throughput caps) — see [`SyncToOptions`]. Destination
1254    /// credentials are supplied via the environment, never on argv, exactly like
1255    /// connect/create. Success is exit code 0.
1256    pub async fn repository_sync_to(
1257        &self,
1258        destination: &ConnectSpec,
1259        opts: &SyncToOptions,
1260    ) -> Result<(), KopiaError> {
1261        self.repository_sync_to_with_env(destination, opts, &BTreeMap::new())
1262            .await
1263    }
1264
1265    /// [`Self::repository_sync_to`] with a per-invocation environment overlay
1266    /// applied to the `sync-to` subprocess only. The replication mover uses this to
1267    /// give the **destination** backend its own credentials: it maps each of the
1268    /// destination backend's env-delivered credential vars (`AWS_*`, `AZURE_*`, …)
1269    /// from the `KOPIUR_DEST_`-prefixed copy in its environment, and unsets any that
1270    /// the destination doesn't provide so a source credential cannot leak. The
1271    /// *source* repository is read from the persisted connection config (kopia bakes
1272    /// the source storage credentials in at `repository connect`), so overlaying the
1273    /// plain credential names here cannot disturb the source read. `Some(v)` sets a
1274    /// var, `None` removes it. Credentials travel via env, never argv.
1275    pub async fn repository_sync_to_with_env(
1276        &self,
1277        destination: &ConnectSpec,
1278        opts: &SyncToOptions,
1279        dest_env: &BTreeMap<String, Option<String>>,
1280    ) -> Result<(), KopiaError> {
1281        let args = sync_to_args(destination, opts);
1282        self.run_ok_with_env(&args, dest_env).await.map(|_| ())
1283    }
1284
1285    /// Create a snapshot of `source_path` with the given `tags`
1286    /// (`key:value`) and kopia's own defaults for `snapshot create`'s tuning
1287    /// knobs. Returns the parsed create result.
1288    ///
1289    /// `override_source`, when set, is passed to kopia as `--override-source`
1290    /// (format `username@hostname:path`). This is how Kopiur records snapshots
1291    /// under the operator-*resolved* identity (ADR §4.2 / anchoring principle 9)
1292    /// rather than the mover pod's ambient `user@host`. Without it kopia would
1293    /// attribute the snapshot to the pod, breaking the identity model that the
1294    /// whole catalog/retention/restore machinery keys on.
1295    pub async fn snapshot_create(
1296        &self,
1297        source_path: &str,
1298        tags: &BTreeMap<String, String>,
1299        override_source: Option<&str>,
1300    ) -> Result<SnapshotCreateResult, KopiaError> {
1301        self.snapshot_create_with(
1302            source_path,
1303            tags,
1304            override_source,
1305            &SnapshotCreateOptions::default(),
1306        )
1307        .await
1308    }
1309
1310    /// Create a snapshot honoring the operator's [`SnapshotCreateOptions`]
1311    /// (`failFast`, `uploadLimitMb`, `description` — M4 flag sweep, issue #216).
1312    /// Same identity/tags contract as [`Self::snapshot_create`], which now
1313    /// delegates here with an all-default `opts` (byte-for-byte the same argv
1314    /// as before this option struct existed).
1315    pub async fn snapshot_create_with(
1316        &self,
1317        source_path: &str,
1318        tags: &BTreeMap<String, String>,
1319        override_source: Option<&str>,
1320        opts: &SnapshotCreateOptions,
1321    ) -> Result<SnapshotCreateResult, KopiaError> {
1322        let args = snapshot_create_args(source_path, tags, override_source, opts);
1323        self.run_json(&args, "snapshot create result").await
1324    }
1325
1326    /// List snapshots, optionally filtered by source identity. With no filter
1327    /// this lists all snapshots in the repository.
1328    pub async fn snapshot_list(
1329        &self,
1330        filter: Option<&SnapshotSource>,
1331    ) -> Result<Vec<SnapshotListEntry>, KopiaError> {
1332        let mut args = vec!["snapshot".into(), "list".into(), "--json".into()];
1333        if let Some(src) = filter {
1334            // kopia accepts the identity string as a positional source filter.
1335            args.push(src.identity());
1336        }
1337        self.run_json(&args, "snapshot list").await
1338    }
1339
1340    /// Delete a single snapshot by manifest id. kopia's `snapshot delete`
1341    /// requires `--delete` to actually remove (otherwise it dry-runs) and does
1342    /// not support `--json`; success is signaled by exit code 0.
1343    ///
1344    /// IDEMPOTENT: an already-absent snapshot (`no snapshots matched <id>` on
1345    /// stderr) is success — that IS the goal state. kopia dedups
1346    /// identical-content snapshot manifests, so several `Snapshot` CRs can
1347    /// legitimately pin the SAME kopia id; when GFS retention prunes more than
1348    /// one of them, the first finalizer's delete removes the manifest and the
1349    /// rest would otherwise fail terminally, wedging their CRs in `Deleting`
1350    /// forever (caught by the retention e2e under suite load).
1351    pub async fn snapshot_delete(&self, id: &str) -> Result<(), KopiaError> {
1352        let args = vec![
1353            "snapshot".into(),
1354            "delete".into(),
1355            id.to_string(),
1356            "--delete".into(),
1357        ];
1358        match self.run_ok(&args).await {
1359            Ok(_) => Ok(()),
1360            Err(KopiaError::NonZeroExit { stderr_tail, .. })
1361                if stderr_tail.contains("no snapshots matched") =>
1362            {
1363                tracing::debug!(%id, "snapshot already absent; delete is idempotent");
1364                Ok(())
1365            }
1366            Err(e) => Err(e),
1367        }
1368    }
1369
1370    /// Restore a snapshot's contents to a target directory with kopia's default
1371    /// options. kopia's `snapshot restore` does not emit JSON; success is exit
1372    /// code 0.
1373    pub async fn snapshot_restore(&self, id: &str, target_dir: &str) -> Result<(), KopiaError> {
1374        self.snapshot_restore_with(id, target_dir, &RestoreOptions::default())
1375            .await
1376    }
1377
1378    /// Restore a snapshot honoring the operator's [`RestoreOptions`]
1379    /// (`enableFileDeletion`, `ignorePermissionErrors`, `writeFilesAtomically`,
1380    /// …). Success is exit code 0.
1381    pub async fn snapshot_restore_with(
1382        &self,
1383        id: &str,
1384        target_dir: &str,
1385        opts: &RestoreOptions,
1386    ) -> Result<(), KopiaError> {
1387        let args = restore_args(id, target_dir, opts);
1388        self.run_ok(&args).await.map(|_| ())
1389    }
1390
1391    /// Verify repository/snapshot integrity (`kopia snapshot verify`). Success is
1392    /// exit code 0; a verification failure surfaces as a non-zero exit.
1393    pub async fn snapshot_verify(&self, opts: &VerifyOptions) -> Result<(), KopiaError> {
1394        let args = verify_args(opts);
1395        self.run_ok(&args).await.map(|_| ())
1396    }
1397
1398    /// Estimate the size/scope of snapshotting `source_path`
1399    /// (`kopia snapshot estimate`). Best-effort; success is exit code 0.
1400    pub async fn snapshot_estimate(&self, source_path: &str) -> Result<(), KopiaError> {
1401        let args = vec![
1402            "snapshot".into(),
1403            "estimate".into(),
1404            source_path.to_string(),
1405        ];
1406        self.run_ok(&args).await.map(|_| ())
1407    }
1408
1409    /// Add a pin to a snapshot so maintenance/expiration never deletes it
1410    /// (`kopia snapshot pin <id> --add <pin>`). Used to protect snapshots whose
1411    /// `Snapshot` carries `deletionPolicy: Retain`.
1412    pub async fn snapshot_pin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
1413        let args = vec![
1414            "snapshot".into(),
1415            "pin".into(),
1416            id.to_string(),
1417            "--add".into(),
1418            pin.to_string(),
1419        ];
1420        self.run_ok(&args).await.map(|_| ())
1421    }
1422
1423    /// Remove a pin from a snapshot (`kopia snapshot pin <id> --remove <pin>`).
1424    pub async fn snapshot_unpin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
1425        let args = vec![
1426            "snapshot".into(),
1427            "pin".into(),
1428            id.to_string(),
1429            "--remove".into(),
1430            pin.to_string(),
1431        ];
1432        self.run_ok(&args).await.map(|_| ())
1433    }
1434
1435    /// Expire snapshots per the repository's policy
1436    /// (`kopia snapshot expire --all`). When `delete` is false this is a dry-run
1437    /// (kopia requires `--delete` to actually remove). Success is exit code 0.
1438    pub async fn snapshot_expire(&self, delete: bool) -> Result<(), KopiaError> {
1439        let mut args = vec!["snapshot".into(), "expire".into(), "--all".into()];
1440        if delete {
1441            args.push("--delete".into());
1442        }
1443        self.run_ok(&args).await.map(|_| ())
1444    }
1445
1446    /// Validate that the connected storage provider behaves correctly
1447    /// (`kopia repository validate-provider`). A good Repository-readiness
1448    /// preflight for object-store backends. Success is exit code 0.
1449    pub async fn repository_validate_provider(&self) -> Result<(), KopiaError> {
1450        let args = vec!["repository".into(), "validate-provider".into()];
1451        self.run_ok(&args).await.map(|_| ())
1452    }
1453
1454    /// Apply a policy to `target` (an identity string, a path, or `--global`)
1455    /// via `kopia policy set`. The operator calls this before the first snapshot
1456    /// so `SnapshotPolicy.spec.policy` (compression/splitter/ignore) is honored.
1457    pub async fn policy_set(&self, target: &str, policy: &PolicyArgs) -> Result<(), KopiaError> {
1458        let args = policy_set_args(target, policy);
1459        self.run_ok(&args).await.map(|_| ())
1460    }
1461
1462    /// Show the effective policy for `target` (`kopia policy show <target>
1463    /// --json`), parsed as a generic JSON value.
1464    pub async fn policy_show(&self, target: &str) -> Result<serde_json::Value, KopiaError> {
1465        let args = vec![
1466            "policy".into(),
1467            "show".into(),
1468            target.to_string(),
1469            "--json".into(),
1470        ];
1471        self.run_json(&args, "policy show").await
1472    }
1473
1474    /// Get repository status (`kopia repository status --json`).
1475    ///
1476    /// This spawns `kopia`, so the example is `no_run` (it would need a real
1477    /// binary + connected repository):
1478    ///
1479    /// ```no_run
1480    /// # async fn run() -> Result<(), kopiur_kopia::KopiaError> {
1481    /// use kopiur_kopia::KopiaClient;
1482    ///
1483    /// let client = KopiaClient::builder()
1484    ///     .env("KOPIA_PASSWORD", "s3cr3t")
1485    ///     .build();
1486    /// let status = client.repository_status().await?;
1487    /// println!("repository unique id: {}", status.unique_id_hex);
1488    /// # Ok(())
1489    /// # }
1490    /// ```
1491    pub async fn repository_status(&self) -> Result<RepositoryStatus, KopiaError> {
1492        let args = vec!["repository".into(), "status".into(), "--json".into()];
1493        self.run_json(&args, "repository status").await
1494    }
1495
1496    /// Get maintenance info (`kopia maintenance info --json`).
1497    pub async fn maintenance_info(&self) -> Result<MaintenanceInfo, KopiaError> {
1498        let args = vec!["maintenance".into(), "info".into(), "--json".into()];
1499        self.run_json(&args, "maintenance info").await
1500    }
1501
1502    /// Count the repository's content-index blobs (`kopia index list --json`,
1503    /// array length). kopia's index is compacted by periodic maintenance; when
1504    /// maintenance stops (e.g. a stale lease owner), this grows unbounded and
1505    /// kopia eventually warns "Found too many index blobs (N), ensure periodic
1506    /// repository maintenance". The operator surfaces a Kubernetes Warning when
1507    /// this crosses a configurable threshold. Cheap — lists index-blob metadata
1508    /// only, no content read. Verified against kopia 0.23 (`index list
1509    /// --[no-]json`).
1510    pub async fn index_blob_count(&self) -> Result<i64, KopiaError> {
1511        let args = vec!["index".into(), "list".into(), "--json".into()];
1512        let entries: Vec<IndexBlobEntry> = self.run_json(&args, "index list").await?;
1513        Ok(entries.len() as i64)
1514    }
1515
1516    /// Claim the repository's maintenance ownership for the *currently connected*
1517    /// identity (`kopia maintenance set --owner me`). kopia ties "who may run
1518    /// maintenance" to the connected user@hostname and rejects a `maintenance run`
1519    /// from anyone but the designated owner ("maintenance must be run by designated
1520    /// user: …"). A repo bootstrapped by the controller in-process is owned by the
1521    /// controller's identity, so a mover Job (a different pod) MUST claim ownership
1522    /// before it can run maintenance. Idempotent; no JSON, success is exit 0.
1523    pub async fn maintenance_set_owner_me(&self) -> Result<(), KopiaError> {
1524        let args = vec![
1525            "maintenance".into(),
1526            "set".into(),
1527            "--owner".into(),
1528            "me".into(),
1529        ];
1530        self.run_ok(&args).await.map(|_| ())
1531    }
1532
1533    /// Set the repository's maintenance owner to an EXPLICIT `user@hostname`
1534    /// (`kopia maintenance set --owner <owner>`). Used by the bootstrap mover
1535    /// right after `repository create` to stamp the stable, lease-derived
1536    /// owner (`kopiur_api::maintenance::kopia_owner_for_lease`) instead of the
1537    /// creating pod's ephemeral identity — without this, every later
1538    /// maintenance mover sees a foreign owner and `takeoverPolicy: Never`
1539    /// yields forever. Verified against kopia 0.23 `maintenance set --help`
1540    /// (`--owner=OWNER  Set maintenance owner user@hostname`).
1541    pub async fn maintenance_set_owner(&self, owner: &str) -> Result<(), KopiaError> {
1542        let args = vec![
1543            "maintenance".into(),
1544            "set".into(),
1545            "--owner".into(),
1546            owner.into(),
1547        ];
1548        self.run_ok(&args).await.map(|_| ())
1549    }
1550
1551    /// Switch the CONNECTED client identity (`kopia repository set-client
1552    /// --username … --hostname …`). The maintenance mover assumes the stable
1553    /// lease-derived identity this way (kopia 0.23 has no identity override on
1554    /// `repository connect`; the OS user@pod-hostname is ephemeral), so
1555    /// `maintenance set --owner me` records a stable string and the
1556    /// designated-user check passes on every later run. Verified against
1557    /// kopia 0.23 `repository set-client --help`.
1558    pub async fn repository_set_client_identity(
1559        &self,
1560        username: &str,
1561        hostname: &str,
1562    ) -> Result<(), KopiaError> {
1563        let args = vec![
1564            "repository".into(),
1565            "set-client".into(),
1566            format!("--username={username}"),
1567            format!("--hostname={hostname}"),
1568        ];
1569        self.run_ok(&args).await.map(|_| ())
1570    }
1571
1572    /// Run a maintenance pass. kopia's `maintenance run` does not emit JSON;
1573    /// success is exit code 0. The caller must already be the designated
1574    /// maintenance owner (see [`maintenance_set_owner_me`](Self::maintenance_set_owner_me)).
1575    pub async fn maintenance_run(&self, mode: MaintenanceMode) -> Result<(), KopiaError> {
1576        let mut args = vec!["maintenance".into(), "run".into()];
1577        match mode {
1578            MaintenanceMode::Quick => args.push("--no-full".into()),
1579            MaintenanceMode::Full => args.push("--full".into()),
1580        }
1581        self.run_ok(&args).await.map(|_| ())
1582    }
1583
1584    /// Run kopia with `args` and stream stdout **byte-for-byte** into `sink`
1585    /// (no line splitting, no UTF-8 assumption), returning the byte count on a
1586    /// zero exit. This is the file-content path for `kopia show <file-oid>`
1587    /// (the browse data-plane's `cat`/`download`), where stdout is the raw
1588    /// object bytes — buffering it whole or splitting it into lines would
1589    /// corrupt/clamp arbitrarily large binary files.
1590    ///
1591    /// stderr is accumulated like every other invocation; a non-zero exit
1592    /// yields [`KopiaError::NonZeroExit`] with the stderr tail. NOTE: bytes
1593    /// already streamed before a late failure have reached the sink — callers
1594    /// writing to a file should verify the count and discard partial output on
1595    /// error (kopia's `show` either streams the object or fails up front, so in
1596    /// practice a failure produces no payload). Honors `default_timeout`.
1597    pub async fn run_raw_streaming(
1598        &self,
1599        args: &[String],
1600        sink: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
1601    ) -> Result<u64, KopiaError> {
1602        let display_args = args.join(" ");
1603        let mut cmd = Command::new(&self.binary);
1604        for (k, v) in &self.common_env {
1605            cmd.env(k, v);
1606        }
1607        cmd.args(args);
1608        cmd.args(&self.common_args);
1609        cmd.stdin(Stdio::null());
1610        cmd.stdout(Stdio::piped());
1611        cmd.stderr(Stdio::piped());
1612
1613        // Same bounded transient-errno retry as `run` (ETXTBSY/EAGAIN are
1614        // fork/exec races, not bad-binary failures).
1615        let mut child = {
1616            let mut attempt = 0u32;
1617            loop {
1618                match cmd.spawn() {
1619                    Ok(c) => break c,
1620                    Err(e) if matches!(e.raw_os_error(), Some(26) | Some(11)) && attempt < 10 => {
1621                        attempt += 1;
1622                        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1623                    }
1624                    Err(source) => {
1625                        return Err(KopiaError::Spawn {
1626                            binary: self.binary.display().to_string(),
1627                            source,
1628                        });
1629                    }
1630                }
1631            }
1632        };
1633        let mut stdout_pipe = child.stdout.take().expect("stdout piped");
1634        let mut stderr_pipe = child.stderr.take().expect("stderr piped");
1635
1636        let copy_out = tokio::io::copy(&mut stdout_pipe, sink);
1637        let read_err = async {
1638            let mut buf = String::new();
1639            stderr_pipe.read_to_string(&mut buf).await.map(|_| buf)
1640        };
1641        let wait_with_io = async {
1642            let (copied, err, status) = tokio::join!(copy_out, read_err, child.wait());
1643            Ok::<_, std::io::Error>((copied?, err?, status?))
1644        };
1645
1646        let (bytes, stderr, status) = match self.default_timeout {
1647            Some(t) => match tokio::time::timeout(t, wait_with_io).await {
1648                Ok(res) => res.map_err(|source| KopiaError::Spawn {
1649                    binary: self.binary.display().to_string(),
1650                    source,
1651                })?,
1652                Err(_) => {
1653                    kill_and_reap(&mut child).await;
1654                    return Err(KopiaError::Timeout {
1655                        args: display_args,
1656                        seconds: t.as_secs(),
1657                    });
1658                }
1659            },
1660            None => wait_with_io.await.map_err(|source| KopiaError::Spawn {
1661                binary: self.binary.display().to_string(),
1662                source,
1663            })?,
1664        };
1665
1666        if status.code() == Some(0) {
1667            Ok(bytes)
1668        } else {
1669            Err(KopiaError::NonZeroExit {
1670                args: display_args,
1671                code: status.code(),
1672                class: KopiaErrorClass::classify(&stderr),
1673                stderr_tail: tail_lines(&stderr),
1674            })
1675        }
1676    }
1677
1678    /// Run `kopia server start`, **replacing this process** with kopia via `exec`.
1679    ///
1680    /// On success this never returns (kopia takes over the PID and runs until it is
1681    /// signalled). It returns a [`KopiaError`] only if `exec` itself fails (e.g. the
1682    /// binary is missing). The repository must already be connected (call
1683    /// [`KopiaClient::repository_connect`] first) — the server reads the connected
1684    /// repo from the kopia config file.
1685    ///
1686    /// `password` is the UI password for [`ServerAuthMode::Password`]; it is appended
1687    /// to argv **here**, inside the server pod, so it never reaches the controller,
1688    /// a ConfigMap, or the pure [`server_start_args`] builder. For
1689    /// [`ServerAuthMode::None`] it is ignored.
1690    #[cfg(unix)]
1691    pub fn server_start(&self, spec: &ServerStartSpec, password: Option<&str>) -> KopiaError {
1692        use std::os::unix::process::CommandExt;
1693
1694        let mut args = server_start_args(spec);
1695        if let ServerAuthMode::Password { .. } = &spec.auth
1696            && let Some(pw) = password
1697        {
1698            args.push("--server-password".into());
1699            args.push(pw.to_string());
1700        }
1701
1702        let mut cmd = std::process::Command::new(&self.binary);
1703        for (k, v) in &self.common_env {
1704            cmd.env(k, v);
1705        }
1706        cmd.args(&args);
1707        cmd.args(&self.common_args);
1708
1709        // exec(2) replaces the current image. It returns ONLY on failure.
1710        let source = cmd.exec();
1711        KopiaError::Spawn {
1712            binary: self.binary.display().to_string(),
1713            source,
1714        }
1715    }
1716}
1717
1718/// Push a kingpin `--[no-]flag` boolean tri-state (`Some(true)` → `--flag`,
1719/// `Some(false)` → `--no-flag`). This is `kopia snapshot restore`'s flag
1720/// grammar (`--[no-]overwrite-files`, …) — NOT `policy set`'s (see
1721/// [`push_valued_tristate`]); the two commands genuinely differ.
1722fn push_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
1723    match value {
1724        Some(true) => args.push(format!("--{flag}")),
1725        Some(false) => args.push(format!("--no-{flag}")),
1726        None => {}
1727    }
1728}
1729
1730/// Push a kopia `policy set` boolean knob. These are VALUED flags
1731/// (`--flag=true|false`, "inherit" being the unset state) — NOT kingpin
1732/// `--flag/--no-flag` booleans: a bare `--ignore-file-errors` fails with
1733/// "expected argument for flag" (caught by the `policy_knobs` e2e; the old
1734/// `--no-` form never reached kopia). Verified against
1735/// `kopia policy set --help` (0.23).
1736fn push_valued_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
1737    match value {
1738        Some(true) => args.push(format!("--{flag}=true")),
1739        Some(false) => args.push(format!("--{flag}=false")),
1740        None => {}
1741    }
1742}
1743
1744/// Split [`PolicyArgs`] into the path-scoped part and an optional
1745/// identity-scoped part. kopia rejects `--max-parallel-snapshots` on a
1746/// path-scoped policy ("max parallel snapshots cannot be specified for paths,
1747/// only global, username@hostname or @hostname" — the `policy_knobs` e2e
1748/// regression), so that one knob must be applied in a second `policy set`
1749/// against the bare `username@hostname` identity. Pure.
1750pub fn split_policy_scopes(mut policy: PolicyArgs) -> (PolicyArgs, Option<PolicyArgs>) {
1751    let identity = policy.max_parallel_snapshots.take().map(|n| PolicyArgs {
1752        max_parallel_snapshots: Some(n),
1753        ..Default::default()
1754    });
1755    (policy, identity)
1756}
1757
1758/// Build the args for `kopia snapshot restore <id> <target>` plus options. Pure
1759/// so it is unit-testable without spawning kopia. Every `--[no-]flag` form here
1760/// was smoke-tested against the pinned kopia 0.23.1 (`kopia snapshot restore
1761/// --help`); the real-kopia integration test in
1762/// `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard that
1763/// kopia actually accepts them, not just that the argv shape looks right.
1764fn restore_args(id: &str, target_dir: &str, opts: &RestoreOptions) -> Vec<String> {
1765    let mut args = vec![
1766        "snapshot".into(),
1767        "restore".into(),
1768        id.to_string(),
1769        target_dir.to_string(),
1770    ];
1771    push_tristate(
1772        &mut args,
1773        "ignore-permission-errors",
1774        opts.ignore_permission_errors,
1775    );
1776    push_tristate(
1777        &mut args,
1778        "write-files-atomically",
1779        opts.write_files_atomically,
1780    );
1781    push_tristate(&mut args, "overwrite-files", opts.overwrite_files);
1782    push_tristate(
1783        &mut args,
1784        "overwrite-directories",
1785        opts.overwrite_directories,
1786    );
1787    push_tristate(&mut args, "overwrite-symlinks", opts.overwrite_symlinks);
1788    push_tristate(&mut args, "write-sparse-files", opts.write_sparse_files);
1789    push_tristate(&mut args, "skip-owners", opts.skip_owners);
1790    push_tristate(&mut args, "skip-permissions", opts.skip_permissions);
1791    push_tristate(&mut args, "skip-times", opts.skip_times);
1792    push_tristate(&mut args, "ignore-errors", opts.ignore_errors);
1793    push_tristate(&mut args, "skip-existing", opts.skip_existing);
1794    push_tristate(&mut args, "delete-extra", opts.delete_extra);
1795    if let Some(p) = opts.parallel {
1796        args.push("--parallel".into());
1797        args.push(p.to_string());
1798    }
1799    args
1800}
1801
1802/// Build the args for `kopia snapshot create <source> --json [flags]` plus
1803/// options. Pure so it is unit-testable without spawning kopia. `--fail-fast`
1804/// is a kingpin `--[no-]flag` tri-state (smoke-tested against the pinned
1805/// kopia 0.23.1: `snapshot create --fail-fast --upload-limit-mb 100
1806/// --description "smoke test"` is accepted; the real-kopia integration test
1807/// in `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard).
1808/// All-default `opts` reproduces the pre-M4 argv byte-for-byte (tested).
1809fn snapshot_create_args(
1810    source_path: &str,
1811    tags: &BTreeMap<String, String>,
1812    override_source: Option<&str>,
1813    opts: &SnapshotCreateOptions,
1814) -> Vec<String> {
1815    let mut args = vec![
1816        "snapshot".into(),
1817        "create".into(),
1818        source_path.to_string(),
1819        "--json".into(),
1820    ];
1821    if let Some(src) = override_source {
1822        args.push("--override-source".into());
1823        args.push(src.to_string());
1824    }
1825    for (k, v) in tags {
1826        args.push("--tags".into());
1827        args.push(format!("{k}:{v}"));
1828    }
1829    push_tristate(&mut args, "fail-fast", opts.fail_fast);
1830    if let Some(mb) = opts.upload_limit_mb {
1831        args.push("--upload-limit-mb".into());
1832        args.push(mb.to_string());
1833    }
1834    if let Some(desc) = &opts.description {
1835        args.push("--description".into());
1836        args.push(desc.clone());
1837    }
1838    args
1839}
1840
1841/// Build the args for `kopia snapshot verify` plus options. Pure.
1842fn verify_args(opts: &VerifyOptions) -> Vec<String> {
1843    let mut args = vec!["snapshot".into(), "verify".into()];
1844    for src in &opts.sources {
1845        args.push("--sources".into());
1846        args.push(src.clone());
1847    }
1848    if let Some(pct) = opts.verify_files_percent {
1849        args.push("--verify-files-percent".into());
1850        args.push(pct.to_string());
1851    }
1852    if let Some(m) = opts.max_errors {
1853        args.push("--max-errors".into());
1854        args.push(m.to_string());
1855    }
1856    if let Some(p) = opts.parallel {
1857        args.push("--parallel".into());
1858        args.push(p.to_string());
1859    }
1860    if let Some(p) = opts.file_parallelism {
1861        args.push("--file-parallelism".into());
1862        args.push(p.to_string());
1863    }
1864    if let Some(q) = opts.file_queue_length {
1865        args.push("--file-queue-length".into());
1866        args.push(q.to_string());
1867    }
1868    args
1869}
1870
1871/// Build the args for `kopia repository connect <backend> [flags]`. Pure so the
1872/// read-only vs read-write argv split is unit-testable without spawning kopia.
1873/// `--readonly` (kopia's persistent read-only client-config bit) is appended
1874/// only for read-only (browse) connects.
1875fn connect_args(spec: &ConnectSpec, cache: CacheTuning, readonly: bool) -> Vec<String> {
1876    let mut args = vec!["repository".into(), "connect".into()];
1877    args.extend(spec.backend_args());
1878    args.extend(cache.args());
1879    if readonly {
1880        args.push("--readonly".into());
1881    }
1882    args
1883}
1884
1885/// Build the args for `kopia repository sync-to <destination> [flags]`. Pure so it
1886/// is unit-testable without spawning kopia (ADR-0005 §13(d) / issue #216). The
1887/// destination's backend selection reuses `ConnectSpec::backend_args`, so every
1888/// backend is wired through. `--must-exist`/`--times`/`--update` are kopia
1889/// (kingpin) BOOLEAN flags: `--must-exist=false` is a parse error (`unexpected
1890/// false`) — but the `--no-must-exist`/`--no-times`/`--no-update` negated forms
1891/// ARE accepted (smoke-tested against kopia 0.23.1), so [`push_tristate`] is used
1892/// for all three exactly like `snapshot restore`'s tri-states. `None` on any
1893/// field omits its flag entirely, leaving kopia's own default in effect.
1894fn sync_to_args(destination: &ConnectSpec, opts: &SyncToOptions) -> Vec<String> {
1895    let mut args = vec!["repository".into(), "sync-to".into()];
1896    args.extend(destination.backend_args());
1897    if let Some(p) = opts.parallel {
1898        args.push("--parallel".into());
1899        args.push(p.to_string());
1900    }
1901    if opts.delete_extra {
1902        args.push("--delete".into());
1903    }
1904    push_tristate(&mut args, "must-exist", opts.must_exist);
1905    push_tristate(&mut args, "times", opts.times);
1906    push_tristate(&mut args, "update", opts.update);
1907    if let Some(s) = opts.max_download_speed_bytes_per_second {
1908        args.push("--max-download-speed".into());
1909        args.push(s.to_string());
1910    }
1911    if let Some(s) = opts.max_upload_speed_bytes_per_second {
1912        args.push("--max-upload-speed".into());
1913        args.push(s.to_string());
1914    }
1915    args
1916}
1917
1918/// Build the args for `kopia policy set <target>` plus flags. Pure.
1919fn policy_set_args(target: &str, policy: &PolicyArgs) -> Vec<String> {
1920    let mut args = vec!["policy".into(), "set".into(), target.to_string()];
1921    if let Some(c) = &policy.compression {
1922        args.push("--compression".into());
1923        args.push(c.clone());
1924    }
1925    if let Some(s) = &policy.splitter {
1926        args.push("--splitter".into());
1927        args.push(s.clone());
1928    }
1929    for pat in &policy.ignore {
1930        args.push("--add-ignore".into());
1931        args.push(pat.clone());
1932    }
1933    for pat in &policy.never_compress {
1934        args.push("--add-never-compress".into());
1935        args.push(pat.clone());
1936    }
1937    push_valued_tristate(&mut args, "ignore-cache-dirs", policy.ignore_cache_dirs);
1938    push_valued_tristate(&mut args, "ignore-file-errors", policy.ignore_file_errors);
1939    push_valued_tristate(&mut args, "ignore-dir-errors", policy.ignore_dir_errors);
1940    push_valued_tristate(
1941        &mut args,
1942        "ignore-unknown-types",
1943        policy.ignore_unknown_types,
1944    );
1945    if let Some(n) = policy.max_parallel_snapshots {
1946        args.push("--max-parallel-snapshots".into());
1947        args.push(n.to_string());
1948    }
1949    if let Some(n) = policy.max_parallel_file_reads {
1950        args.push("--max-parallel-file-reads".into());
1951        args.push(n.to_string());
1952    }
1953    if let Some(n) = policy.keep_latest {
1954        args.push("--keep-latest".into());
1955        args.push(n.to_string());
1956    }
1957    if let Some(n) = policy.keep_hourly {
1958        args.push("--keep-hourly".into());
1959        args.push(n.to_string());
1960    }
1961    if let Some(n) = policy.keep_daily {
1962        args.push("--keep-daily".into());
1963        args.push(n.to_string());
1964    }
1965    if let Some(n) = policy.keep_weekly {
1966        args.push("--keep-weekly".into());
1967        args.push(n.to_string());
1968    }
1969    if let Some(n) = policy.keep_monthly {
1970        args.push("--keep-monthly".into());
1971        args.push(n.to_string());
1972    }
1973    if let Some(n) = policy.keep_annual {
1974        args.push("--keep-annual".into());
1975        args.push(n.to_string());
1976    }
1977    args.extend(policy.extra_args.iter().cloned());
1978    args
1979}
1980
1981/// Build the args for `kopia server start` (everything except the secret password,
1982/// which [`KopiaClient::server_start`] appends at exec time). Pure and unit-testable.
1983///
1984/// Always emits `--insecure` (no in-pod TLS; the user's ingress terminates TLS).
1985/// [`ServerAuthMode::Password`] emits `--server-username`; [`ServerAuthMode::None`]
1986/// emits `--without-password`.
1987fn server_start_args(spec: &ServerStartSpec) -> Vec<String> {
1988    let mut args = vec![
1989        "server".into(),
1990        "start".into(),
1991        "--address".into(),
1992        spec.address.clone(),
1993        // No in-pod TLS — this is kopia's *no-TLS* switch, required in every mode.
1994        "--insecure".into(),
1995    ];
1996    if spec.ui {
1997        args.push("--ui".into());
1998    }
1999    match &spec.auth {
2000        ServerAuthMode::Password { username } => {
2001            args.push("--server-username".into());
2002            args.push(username.clone());
2003        }
2004        ServerAuthMode::None => {
2005            args.push("--without-password".into());
2006            // kopia 0.23+ refuses to bind a non-loopback address with
2007            // `--insecure --without-password` unless this escape hatch is set
2008            // (it is exactly the "exposed unauthenticated server" the project gates
2009            // behind `acknowledgeInsecure`). We always bind `0.0.0.0` so the Service
2010            // can reach the server, so the flag is required here.
2011            args.push("--allow-extremely-dangerous-unauthenticated-server-on-the-network".into());
2012        }
2013    }
2014    args
2015}
2016
2017/// Extract the JSON result from kopia stdout. kopia prints a single JSON object
2018/// or array; progress goes to stderr. We find the first `{` or `[` and return
2019/// the trimmed remainder, which is the JSON value. Returns `None` if stdout
2020/// contains no `{`/`[`.
2021fn extract_json(stdout: &str) -> Option<&str> {
2022    let trimmed = stdout.trim();
2023    let start = trimmed.find(['{', '['])?;
2024    Some(trimmed[start..].trim())
2025}
2026
2027#[cfg(test)]
2028mod tests;