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