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;
24use tokio::task::JoinHandle;
25
26use crate::error::{KopiaError, KopiaErrorClass, tail_lines};
27use crate::model::{
28    IndexBlobEntry, MaintenanceInfo, RepositoryStatus, SnapshotCreateOutcome, SnapshotCreateResult,
29    SnapshotListEntry, SnapshotSource,
30};
31
32/// Which maintenance pass to run.
33///
34/// `Serialize`/`Deserialize` so the mover work-spec can carry the mode as one
35/// shared type (no parallel enum in `kopiur-mover`). Wire form is the camelCase
36/// variant name (`"quick"` / `"full"`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub enum MaintenanceMode {
40    /// `kopia maintenance run --no-full` — index compaction, epoch advance.
41    Quick,
42    /// `kopia maintenance run --full` — content GC + rewrite.
43    Full,
44}
45
46/// A typed description of how to reach a kopia repository. This is the input to
47/// [`KopiaClient::repository_connect`] / [`KopiaClient::repository_create`].
48/// Externally-tagged so exactly one backend is representable (mirrors the API
49/// crate's `Backend` discipline, though this is a separate, simpler type with
50/// no kube dependency).
51///
52/// ## Credentials are NOT here
53///
54/// Secrets are supplied two ways, never on argv. Env-delivered secrets (set with
55/// [`KopiaClientBuilder::env`]) cover the backends kopia reads from the
56/// environment; file-delivered secrets are written to a file by the caller (the
57/// mover) and the *path* is passed in the relevant `ConnectSpec` field. Either
58/// way only non-secret identifiers (bucket, host, path, …) and file *paths* live
59/// in `ConnectSpec`, so a secret never leaks into a ConfigMap, a process listing,
60/// or an error message. The relevant kopia inputs by backend:
61///   * S3:    `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` (env)
62///   * Azure: `AZURE_STORAGE_KEY` / `AZURE_STORAGE_SAS_TOKEN` (env; or SP env)
63///   * B2:    `B2_KEY_ID`, `B2_KEY` (env)
64///   * WebDAV:`KOPIA_WEBDAV_USERNAME`, `KOPIA_WEBDAV_PASSWORD` (env)
65///   * GCS:   `Gcs::credentials_file` → `--credentials-file` (a JSON file path)
66///   * SFTP:  `Sftp::keyfile`/`known_hosts` → `--keyfile`/`--known-hosts` (file paths)
67///   * rclone:`Rclone::config_file` → rclone `--config` (a file path)
68///   * all:   `KOPIA_PASSWORD` (the repository encryption password; env)
69///
70/// This is the full set of kopia 0.23 `repository connect/create` backends. The
71/// operator's CRD `Backend` enum maps onto the first eight; `Gdrive`,
72/// `FromConfig`, and `Server` are exposed for client completeness (a kopia
73/// client connecting to an existing kopia API server is a legitimate backend —
74/// distinct from *running* a server, which the operator deliberately does not do).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ConnectSpec {
77    /// Filesystem backend at a local path (used in-cluster for hostPath/PVC
78    /// repos and in tests).
79    Filesystem {
80        /// Absolute path to the repository root.
81        path: PathBuf,
82    },
83    /// S3-compatible backend.
84    S3 {
85        /// Bucket name.
86        bucket: String,
87        /// Optional custom endpoint (for MinIO / non-AWS).
88        endpoint: Option<String>,
89        /// Optional key prefix within the bucket.
90        prefix: Option<String>,
91        /// Region, if required by the endpoint.
92        region: Option<String>,
93        /// Talk plain HTTP to the endpoint (`--disable-tls`). For HTTP-only
94        /// endpoints (in-cluster MinIO/RustFS); kopia otherwise assumes HTTPS.
95        disable_tls: bool,
96        /// Skip TLS certificate verification (`--disable-tls-verification`).
97        disable_tls_verification: bool,
98        /// PEM CA bundle used to verify the endpoint's certificate
99        /// (`--root-ca-pem-base64`, base64-encoded on argv — a CA cert is
100        /// public, and the base64 form needs no file the way
101        /// `--root-ca-pem-path` would). kopia persists it into the connection
102        /// config (`s3.Options.RootCA`, `json:"rootCA"`), so one connect covers
103        /// every later verb reading that config — including an exec'd
104        /// `kopia server start`. NOTE: kopia builds a FRESH cert pool from this
105        /// bundle for the S3 connection (it does not extend the system roots),
106        /// so a chain that also needs public roots must include them in the
107        /// bundle.
108        root_ca_pem: Option<String>,
109        /// Authenticate via the ambient AWS credential chain (IRSA web-identity,
110        /// EKS Pod Identity, IMDS) instead of static keys — the workload-identity
111        /// path. kopia 0.23 marks `--access-key`/`--secret-access-key` as
112        /// *required* flags (env-bound to `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`),
113        /// but its storage layer skips empty static credentials and falls through
114        /// minio-go's chain — so this renders the flags **explicitly empty**
115        /// (`--access-key=`), which satisfies the parser and engages the chain.
116        ambient_credentials: bool,
117    },
118    /// Azure Blob Storage backend.
119    Azure {
120        /// Blob container name.
121        container: String,
122        /// Storage account name (when not supplied via env).
123        storage_account: Option<String>,
124        /// Optional object prefix.
125        prefix: Option<String>,
126    },
127    /// Google Cloud Storage backend.
128    Gcs {
129        /// Bucket name.
130        bucket: String,
131        /// Optional object prefix.
132        prefix: Option<String>,
133        /// Path to a JSON service-account credentials file inside the mover pod
134        /// (`--credentials-file`). The mover materializes this from the
135        /// credentials Secret at runtime; `None` falls back to ambient ADC.
136        credentials_file: Option<String>,
137    },
138    /// Backblaze B2 backend.
139    B2 {
140        /// Bucket name.
141        bucket: String,
142        /// Optional object prefix.
143        prefix: Option<String>,
144    },
145    /// SFTP/SSH backend.
146    Sftp {
147        /// Server hostname.
148        host: String,
149        /// Path to the repository on the server.
150        path: String,
151        /// Server port (defaults to 22 when `None`).
152        port: Option<u16>,
153        /// SSH username.
154        username: Option<String>,
155        /// Path to a private key file inside the mover pod (`--keyfile`). The
156        /// mover materializes this from the credentials Secret at runtime.
157        keyfile: Option<String>,
158        /// Path to a `known_hosts` file inside the mover pod (`--known-hosts`),
159        /// pinning the server host key. The mover materializes this from the
160        /// credentials Secret at runtime.
161        known_hosts: Option<String>,
162    },
163    /// WebDAV backend.
164    WebDav {
165        /// WebDAV server URL.
166        url: String,
167    },
168    /// Rclone backend (shells out to an `rclone` binary).
169    Rclone {
170        /// Rclone `remote:path`.
171        remote_path: String,
172        /// Path to an `rclone.conf` inside the mover pod, forwarded to rclone via
173        /// `--rclone-args=--config=<path>`. The mover materializes this from the
174        /// config Secret at runtime; `None` uses rclone's default config lookup.
175        config_file: Option<String>,
176        /// Go-duration value for kopia's `--rclone-startup-timeout` (how long to
177        /// wait for the embedded `rclone serve` to come up). `None` leaves kopia's
178        /// default (`15s`).
179        startup_timeout: Option<String>,
180    },
181    /// Google Drive backend.
182    Gdrive {
183        /// Drive folder id that holds the repository.
184        folder_id: String,
185        /// Path to a Google service-account JSON inside the mover pod, passed as
186        /// `--credentials-file`. The mover materializes this from the credentials
187        /// Secret at runtime; `None` uses kopia's ambient credential lookup.
188        credentials_file: Option<String>,
189    },
190    /// Reconnect from a kopia configuration token/file (`repository connect
191    /// from-config`). Exactly one of `file`/`token` is meaningful.
192    FromConfig {
193        /// Path to a kopia config file.
194        file: Option<String>,
195        /// A kopia configuration token.
196        token: Option<String>,
197    },
198    /// Connect to an existing kopia API server as a client.
199    Server {
200        /// Server URL.
201        url: String,
202        /// Expected server TLS certificate fingerprint (sha256 hex).
203        fingerprint: Option<String>,
204    },
205}
206
207/// Per-connection kopia cache budgets, applied at `repository connect`/`create`
208/// time (`--content-cache-size-mb` / `--metadata-cache-size-mb`). Each mover pod
209/// connects fresh, so these size that pod's local cache. `None` leaves kopia's
210/// default. Serializable so it rides the mover work spec from controller to mover.
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct CacheTuning {
214    /// `--content-cache-size-mb`: content (data) cache budget in MiB.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub content_cache_size_mb: Option<i64>,
217    /// `--metadata-cache-size-mb`: metadata cache budget in MiB.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub metadata_cache_size_mb: Option<i64>,
220}
221
222impl CacheTuning {
223    /// Whether no budgets are set (so the connect command adds no cache flags).
224    pub fn is_unset(&self) -> bool {
225        self.content_cache_size_mb.is_none() && self.metadata_cache_size_mb.is_none()
226    }
227
228    /// The `--content-cache-size-mb` / `--metadata-cache-size-mb` args for the set
229    /// budgets, in a stable order. Empty when nothing is set.
230    fn args(&self) -> Vec<String> {
231        let mut a = Vec::new();
232        if let Some(mb) = self.content_cache_size_mb {
233            a.push("--content-cache-size-mb".into());
234            a.push(mb.to_string());
235        }
236        if let Some(mb) = self.metadata_cache_size_mb {
237            a.push("--metadata-cache-size-mb".into());
238            a.push(mb.to_string());
239        }
240        a
241    }
242}
243
244impl ConnectSpec {
245    /// Stable discriminant string for logging/metrics (mirrors
246    /// `kopiur_api::backend::Backend::kind_str`).
247    ///
248    /// ```
249    /// use std::path::PathBuf;
250    /// use kopiur_kopia::ConnectSpec;
251    ///
252    /// let fs = ConnectSpec::Filesystem { path: PathBuf::from("/repo") };
253    /// assert_eq!(fs.kind_str(), "filesystem");
254    ///
255    /// let s3 = ConnectSpec::S3 {
256    ///     bucket: "backups".into(),
257    ///     endpoint: Some("https://minio.local".into()),
258    ///     prefix: None,
259    ///     region: None,
260    ///     disable_tls: false,
261    ///     disable_tls_verification: false,
262    ///     ambient_credentials: false,
263    ///     root_ca_pem: None,
264    /// };
265    /// assert_eq!(s3.kind_str(), "s3");
266    /// ```
267    pub fn kind_str(&self) -> &'static str {
268        match self {
269            ConnectSpec::Filesystem { .. } => "filesystem",
270            ConnectSpec::S3 { .. } => "s3",
271            ConnectSpec::Azure { .. } => "azure",
272            ConnectSpec::Gcs { .. } => "gcs",
273            ConnectSpec::B2 { .. } => "b2",
274            ConnectSpec::Sftp { .. } => "sftp",
275            ConnectSpec::WebDav { .. } => "webdav",
276            ConnectSpec::Rclone { .. } => "rclone",
277            ConnectSpec::Gdrive { .. } => "gdrive",
278            ConnectSpec::FromConfig { .. } => "from-config",
279            ConnectSpec::Server { .. } => "server",
280        }
281    }
282
283    /// The environment-variable names kopia reads this backend's credentials from
284    /// **directly** (no intermediate file). These are exactly the vars the
285    /// replication mover remaps from their `KOPIUR_DEST_`-prefixed copies onto their
286    /// plain names for the `sync-to` subprocess, so the destination authenticates
287    /// with its own keys instead of the source's identically named ones (issue #200).
288    ///
289    /// Exhaustive over [`ConnectSpec`] so a new backend cannot compile until its
290    /// credential-delivery mechanism is decided. File-based backends (GCS/SFTP/
291    /// Rclone/Gdrive) deliver credentials as a materialized *file* whose path is on
292    /// argv, so they read no direct credential env var and return `&[]` — the mover
293    /// stages their destination file separately. `AWS_WEB_IDENTITY_TOKEN_FILE` and
294    /// the other ambient-chain *hints* are deliberately excluded: they belong to the
295    /// pod's ServiceAccount (a workload-identity destination), not to a credential
296    /// Secret, and must never be remapped or unset.
297    pub fn direct_credential_env_names(&self) -> &'static [&'static str] {
298        match self {
299            ConnectSpec::S3 { .. } => &[
300                "AWS_ACCESS_KEY_ID",
301                "AWS_SECRET_ACCESS_KEY",
302                "AWS_SESSION_TOKEN",
303            ],
304            // Static shared key / SAS token, plus the service-principal env trio
305            // (the same names kopia's static Azure auth reads).
306            ConnectSpec::Azure { .. } => &[
307                "AZURE_STORAGE_KEY",
308                "AZURE_STORAGE_SAS_TOKEN",
309                "AZURE_TENANT_ID",
310                "AZURE_CLIENT_ID",
311                "AZURE_CLIENT_SECRET",
312            ],
313            ConnectSpec::B2 { .. } => &["B2_KEY_ID", "B2_KEY"],
314            ConnectSpec::WebDav { .. } => &["KOPIA_WEBDAV_USERNAME", "KOPIA_WEBDAV_PASSWORD"],
315            // File-delivered (materialized to a path) or credential-free.
316            ConnectSpec::Filesystem { .. }
317            | ConnectSpec::Gcs { .. }
318            | ConnectSpec::Sftp { .. }
319            | ConnectSpec::Rclone { .. }
320            | ConnectSpec::Gdrive { .. }
321            | ConnectSpec::FromConfig { .. }
322            | ConnectSpec::Server { .. } => &[],
323        }
324    }
325
326    /// The kopia subcommand args that select this backend, e.g.
327    /// `["filesystem", "--path", "/repo"]`. Used by both connect and create.
328    /// Credentials are expected in the environment, never here (see the type
329    /// docs). A new backend variant cannot compile until it is handled.
330    fn backend_args(&self) -> Vec<String> {
331        // Push `--flag value` only when the optional value is present.
332        fn opt(a: &mut Vec<String>, flag: &str, value: &Option<String>) {
333            if let Some(v) = value {
334                a.push(flag.into());
335                a.push(v.clone());
336            }
337        }
338        match self {
339            ConnectSpec::Filesystem { path } => {
340                vec![
341                    "filesystem".into(),
342                    "--path".into(),
343                    path.display().to_string(),
344                ]
345            }
346            ConnectSpec::S3 {
347                bucket,
348                endpoint,
349                prefix,
350                region,
351                disable_tls,
352                disable_tls_verification,
353                root_ca_pem,
354                ambient_credentials,
355            } => {
356                let mut a = vec!["s3".into(), "--bucket".into(), bucket.clone()];
357                opt(&mut a, "--endpoint", endpoint);
358                opt(&mut a, "--prefix", prefix);
359                opt(&mut a, "--region", region);
360                if *disable_tls {
361                    a.push("--disable-tls".into());
362                }
363                if *disable_tls_verification {
364                    a.push("--disable-tls-verification".into());
365                }
366                if let Some(pem) = root_ca_pem {
367                    use base64::Engine as _;
368                    a.push("--root-ca-pem-base64".into());
369                    a.push(base64::engine::general_purpose::STANDARD.encode(pem));
370                }
371                if *ambient_credentials {
372                    // Single `=`-joined tokens: an empty value as a separate argv
373                    // token (`--access-key ""`) would be consumed as the flag's
374                    // value either way, but the joined form is unambiguous to
375                    // kingpin and to a human reading the Job args. Satisfies the
376                    // Required() flags with empty values so kopia's storage layer
377                    // falls through to the ambient chain (IRSA / Pod Identity / IMDS).
378                    a.push("--access-key=".into());
379                    a.push("--secret-access-key=".into());
380                }
381                a
382            }
383            ConnectSpec::Azure {
384                container,
385                storage_account,
386                prefix,
387            } => {
388                let mut a = vec!["azure".into(), "--container".into(), container.clone()];
389                opt(&mut a, "--storage-account", storage_account);
390                opt(&mut a, "--prefix", prefix);
391                a
392            }
393            ConnectSpec::Gcs {
394                bucket,
395                prefix,
396                credentials_file,
397            } => {
398                let mut a = vec!["gcs".into(), "--bucket".into(), bucket.clone()];
399                opt(&mut a, "--prefix", prefix);
400                opt(&mut a, "--credentials-file", credentials_file);
401                a
402            }
403            ConnectSpec::B2 { bucket, prefix } => {
404                let mut a = vec!["b2".into(), "--bucket".into(), bucket.clone()];
405                opt(&mut a, "--prefix", prefix);
406                a
407            }
408            ConnectSpec::Sftp {
409                host,
410                path,
411                port,
412                username,
413                keyfile,
414                known_hosts,
415            } => {
416                let mut a = vec![
417                    "sftp".into(),
418                    "--host".into(),
419                    host.clone(),
420                    "--path".into(),
421                    path.clone(),
422                ];
423                if let Some(p) = port {
424                    a.push("--port".into());
425                    a.push(p.to_string());
426                }
427                opt(&mut a, "--username", username);
428                opt(&mut a, "--keyfile", keyfile);
429                opt(&mut a, "--known-hosts", known_hosts);
430                a
431            }
432            ConnectSpec::WebDav { url } => {
433                vec!["webdav".into(), "--url".into(), url.clone()]
434            }
435            ConnectSpec::Rclone {
436                remote_path,
437                config_file,
438                startup_timeout,
439            } => {
440                let mut a = vec!["rclone".into(), "--remote-path".into(), remote_path.clone()];
441                // Forward the rclone config path to the embedded rclone. Must be a
442                // SINGLE `--rclone-args=<value>` token: kopia's CLI parser treats a
443                // separate value starting with `--` as the next flag, so
444                // `--rclone-args --config=…` fails with "expected argument".
445                if let Some(cfg) = config_file {
446                    a.push(format!("--rclone-args=--config={cfg}"));
447                }
448                // kopia's own connect flag (not an rclone arg): how long to wait
449                // for the embedded `rclone serve` before failing the connect.
450                if let Some(t) = startup_timeout {
451                    a.push(format!("--rclone-startup-timeout={t}"));
452                }
453                a
454            }
455            ConnectSpec::Gdrive {
456                folder_id,
457                credentials_file,
458            } => {
459                let mut a = vec!["gdrive".into(), "--folder-id".into(), folder_id.clone()];
460                opt(&mut a, "--credentials-file", credentials_file);
461                a
462            }
463            ConnectSpec::FromConfig { file, token } => {
464                let mut a = vec!["from-config".into()];
465                opt(&mut a, "--file", file);
466                opt(&mut a, "--token", token);
467                a
468            }
469            ConnectSpec::Server { url, fingerprint } => {
470                let mut a = vec!["server".into(), "--url".into(), url.clone()];
471                opt(&mut a, "--server-cert-fingerprint", fingerprint);
472                a
473            }
474        }
475    }
476}
477
478/// Options for `kopia repository connect` beyond backend/cache selection.
479/// `Copy` + `Default` so the common read-write, non-persisted connect is
480/// `ConnectOptions::default()` — which produces byte-identical argv to a
481/// pre-`ConnectOptions` [`KopiaClient::repository_connect`].
482#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
483pub struct ConnectOptions {
484    /// `--readonly`: persist kopia's read-only bit into the client config, so
485    /// every subsequent invocation on this connection is structurally unable
486    /// to mutate the repository (browse sessions; replication source connects).
487    pub readonly: bool,
488    /// `--persist-credentials`: write the repository password beside the
489    /// config file (`<config>.kopia-password`) so later invocations reading
490    /// that config — including `kopia snapshot migrate --source-config` run
491    /// under a DIFFERENT `KOPIA_PASSWORD` — authenticate with the persisted
492    /// password. kopia's own default is already persist-on; passing it
493    /// explicitly pins the contract rather than relying on the default.
494    pub persist_credentials: bool,
495}
496
497/// Which source-repository snapshot sources `kopia snapshot migrate` copies.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub enum MigrateSources {
500    /// `--all`: migrate every source present in the source repository.
501    All,
502    /// One `--sources <spec>` per entry. Each spec is a kopia source triple
503    /// rendered as `username@hostname:/path` (see
504    /// [`crate::model::SnapshotSource::identity`]).
505    List(Vec<String>),
506}
507
508/// How `kopia snapshot migrate` treats kopia-side policies on the destination.
509///
510/// kopia's OWN default is `--policies` **true** (copy policies), so
511/// [`MigratePolicies::None`] must be rendered as an EXPLICIT `--no-policies` —
512/// omitting the flag would silently import the source repository's kopia
513/// policies (retention among them) into the destination.
514#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
515pub enum MigratePolicies {
516    /// `--no-policies`: copy no kopia policies (kopiur's default — the
517    /// operator owns policy on both sides).
518    #[default]
519    None,
520    /// `--policies`: copy policies for the migrated sources, keeping any that
521    /// already exist on the destination.
522    Copy,
523    /// `--policies --overwrite-policies`: copy policies, overwriting existing
524    /// destination policies for the migrated sources.
525    CopyOverwrite,
526}
527
528/// Options for `kopia snapshot migrate --source-config <path>` — logical
529/// (snapshot-level) replication from another repository into the CONNECTED
530/// one. See [`KopiaClient::snapshot_migrate`] for the execution contract.
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct SnapshotMigrateOptions {
533    /// `--source-config`: path to the kopia config file of the SOURCE
534    /// repository. The source password is read from that config's persisted
535    /// credentials (`<config>.kopia-password` — see
536    /// [`ConnectOptions::persist_credentials`]), never from this client's
537    /// `KOPIA_PASSWORD` (which belongs to the connected destination).
538    pub source_config_path: String,
539    /// Which sources to migrate.
540    pub sources: MigrateSources,
541    /// `--latest-only`: migrate only the latest snapshot per source.
542    pub latest_only: bool,
543    /// `--parallel <n>`: how many sources to migrate concurrently.
544    pub parallel: Option<u32>,
545    /// Policy copy mode (kopiur defaults to [`MigratePolicies::None`]).
546    pub policies: MigratePolicies,
547}
548
549/// Options for `kopia snapshot verify`. All fields default to kopia's defaults
550/// when `None`/empty.
551#[derive(Debug, Clone, Default, PartialEq, Eq)]
552pub struct VerifyOptions {
553    /// `--sources`: restrict verification to these kopia sources
554    /// (`username@hostname:path`). Empty (the default) verifies EVERY snapshot
555    /// in the repository — kopia's own default. For a per-`SnapshotPolicy`
556    /// verify against a shared repository that is both wrong (it re-verifies
557    /// every other policy's data under a different identity) and expensive
558    /// (`verifyFilesPercent` then samples the WHOLE repository, not just this
559    /// policy's snapshots — issue #250), so the controller always scopes a
560    /// quick verify to the policy's resolved identity.
561    pub sources: Vec<String>,
562    /// `--verify-files-percent`: randomly fully-read this percentage of files.
563    pub verify_files_percent: Option<u8>,
564    /// `--max-errors`: stop after this many errors (0 = never stop early).
565    pub max_errors: Option<u32>,
566    /// `--parallel`: verification parallelism (kopia default: 8).
567    pub parallel: Option<u32>,
568    /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
569    pub file_parallelism: Option<u32>,
570    /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
571    pub file_queue_length: Option<u32>,
572}
573
574/// Options for `kopia snapshot create` (M4 flag sweep, issue #216 category
575/// sweep). All-default reproduces kopia's own defaults / today's argv:
576/// `fail_fast: None` (kopia default: keep going past per-file errors, subject
577/// to the `errorHandling.ignore*Errors` policy knobs), `upload_limit_mb: None`
578/// (kopia default: unlimited), `description: None` (kopia default: empty).
579#[derive(Debug, Clone, Default, PartialEq, Eq)]
580pub struct SnapshotCreateOptions {
581    /// `--[no-]fail-fast`: abort the snapshot at the first error instead of
582    /// collecting and continuing (kopia default: false — collect and continue).
583    pub fail_fast: Option<bool>,
584    /// `--upload-limit-mb`: abort the snapshot once this many MB have been
585    /// uploaded (kopia default: 0 — unlimited).
586    pub upload_limit_mb: Option<i64>,
587    /// `--description`: free-form text recorded on the snapshot manifest
588    /// (kopia default: empty).
589    pub description: Option<String>,
590}
591
592/// Options for `kopia restore` / `kopia snapshot restore`. The tri-state
593/// booleans map to kopia's `--[no-]flag` form: `Some(true)` → `--flag`,
594/// `Some(false)` → `--no-flag`, `None` → omit (kopia default). M2 flag sweep
595/// (issue #216 gap analysis) added everything below `overwrite_files`; all of
596/// them, plus `delete_extra`, were previously either absent or dormant (the
597/// mover's `RestoreOp::restore_options()` dropped them via `..Default::default()`).
598#[derive(Debug, Clone, Default, PartialEq, Eq)]
599pub struct RestoreOptions {
600    /// `--[no-]ignore-permission-errors` (kopia default: true).
601    pub ignore_permission_errors: Option<bool>,
602    /// `--[no-]write-files-atomically`.
603    pub write_files_atomically: Option<bool>,
604    /// `--[no-]overwrite-files` (kopia default: true).
605    pub overwrite_files: Option<bool>,
606    /// `--[no-]overwrite-directories` (kopia default: true).
607    pub overwrite_directories: Option<bool>,
608    /// `--[no-]overwrite-symlinks` (kopia default: true).
609    pub overwrite_symlinks: Option<bool>,
610    /// `--[no-]write-sparse-files` (kopia default: false).
611    pub write_sparse_files: Option<bool>,
612    /// `--[no-]skip-owners` (kopia default: false).
613    pub skip_owners: Option<bool>,
614    /// `--[no-]skip-permissions` (kopia default: false).
615    pub skip_permissions: Option<bool>,
616    /// `--[no-]skip-times` (kopia default: false).
617    pub skip_times: Option<bool>,
618    /// `--[no-]ignore-errors` (kopia default: false).
619    pub ignore_errors: Option<bool>,
620    /// `--[no-]skip-existing`: skip files/symlinks that already exist in the
621    /// target (kopia default: false). A genuine kingpin tri-state, not a
622    /// presence-only flag — widened from a bare `bool`.
623    pub skip_existing: Option<bool>,
624    /// `--[no-]delete-extra`: delete files/directories/symlinks present in the
625    /// restore path but absent from the snapshot (kopia default: false). Backs
626    /// `Restore.spec.options.enableFileDeletion`, which was previously a silent
627    /// no-op — this struct had no field for it at all.
628    pub delete_extra: Option<bool>,
629    /// `--parallel`: restore parallelism (1 disables).
630    pub parallel: Option<u32>,
631}
632
633/// Options for `kopia repository sync-to` (ADR-0005 §13(d) / issue #216). Every
634/// field's `None`/`false` reproduces kopia's own default — an all-`None`,
635/// `delete_extra: false` instance yields the exact same argv `sync_to_args`
636/// produced before this struct existed. The tri-state booleans map to kopia's
637/// `--[no-]flag` grammar, same as [`RestoreOptions`]: `Some(true)` → `--flag`,
638/// `Some(false)` → `--no-flag`, `None` → omit (kopia default).
639#[derive(Debug, Clone, Default, PartialEq, Eq)]
640pub struct SyncToOptions {
641    /// `--parallel`: copy parallelism to the destination (kopia default `1` —
642    /// sequential; the root cause of #216's multi-week initial-seed times).
643    pub parallel: Option<u32>,
644    /// `--delete`: prune destination-only blobs for a true mirror (kopia
645    /// default `false` — additive sync, never removes destination content).
646    pub delete_extra: bool,
647    /// `--[no-]must-exist`: fail instead of initializing the destination's
648    /// repository-format blob (kopia default `false`).
649    pub must_exist: Option<bool>,
650    /// `--[no-]times`: synchronize blob modification times to the destination,
651    /// when supported (kopia default `true`).
652    pub times: Option<bool>,
653    /// `--[no-]update`: update blobs already present at the destination when
654    /// the source copy is newer (kopia default `true`).
655    pub update: Option<bool>,
656    /// `--max-download-speed`: cap read throughput from the source, bytes/sec
657    /// (kopia default: unlimited).
658    pub max_download_speed_bytes_per_second: Option<i64>,
659    /// `--max-upload-speed`: cap write throughput to the destination, bytes/sec
660    /// (kopia default: unlimited).
661    pub max_upload_speed_bytes_per_second: Option<i64>,
662}
663
664/// Policy fields kopia applies via `kopia policy set`. Mirrors the operator's
665/// `SnapshotPolicy.spec.policy` without depending on the api crate, so the kopia
666/// crate stays controller-agnostic. The caller translates the CRD policy into
667/// this and the controller applies it before the first snapshot.
668#[derive(Debug, Clone, Default, PartialEq, Eq)]
669pub struct PolicyArgs {
670    /// `--compression` algorithm (e.g. `zstd`, `none`).
671    pub compression: Option<String>,
672    /// `--splitter` algorithm.
673    pub splitter: Option<String>,
674    /// `--add-ignore` glob patterns.
675    pub ignore: Vec<String>,
676    /// `--add-never-compress` glob patterns.
677    pub never_compress: Vec<String>,
678    /// `--ignore-cache-dirs` tri-state (honor `CACHEDIR.TAG`). `None` leaves kopia's default.
679    pub ignore_cache_dirs: Option<bool>,
680    /// `--ignore-identical-snapshots` tri-state: skip writing a manifest when
681    /// the source is byte-identical to the previous snapshot.
682    ///
683    /// This is a kopia **retention** knob, not a files one, despite living
684    /// beside `ignore-cache-dirs` on the `policy set` command line.
685    ///
686    /// Kopiur pins it explicitly rather than inheriting: kopia's own default is
687    /// `false`, but a repository's global policy (or a third-party `extraArgs`)
688    /// can turn it on out of band, and a deduped run writes no manifest — which
689    /// breaks the one-`Snapshot`-CR-owns-one-manifest invariant the finalizer,
690    /// retention and restore all rest on. The mover therefore pins `false` at
691    /// the identity scope on every run, and only an explicit
692    /// `files.ignoreIdenticalSnapshots: true` raises it at the (more specific)
693    /// path scope. See #351.
694    pub ignore_identical_snapshots: Option<bool>,
695    /// Backup-side error handling (`--ignore-file-errors`) tri-state. ADR-0005 §13(b).
696    pub ignore_file_errors: Option<bool>,
697    /// `--ignore-dir-errors` tri-state. ADR-0005 §13(b).
698    pub ignore_dir_errors: Option<bool>,
699    /// `--ignore-unknown-types` tri-state. ADR-0005 §13(b).
700    pub ignore_unknown_types: Option<bool>,
701    /// `--max-parallel-snapshots` upload parallelism. ADR-0005 §13(f).
702    pub max_parallel_snapshots: Option<u32>,
703    /// `--max-parallel-file-reads` upload parallelism. ADR-0005 §13(f).
704    pub max_parallel_file_reads: Option<u32>,
705    /// `--keep-latest`: most-recent-N backups to keep per source.
706    ///
707    /// This field (and its five siblings below) exists ONLY so the mover can
708    /// pin kopia's own create-time retention to effectively-infinite at the
709    /// identity scope — see `kopiur_mover::workspec::KOPIA_KEEP_MAX`'s doc
710    /// comment for the full hazard. There is deliberately no CRD/workspec
711    /// surface that lets a user set these: kopia-side retention stays
712    /// forbidden (`crates/api/src/error.rs`'s `InlineRetentionForbidden`),
713    /// and `PolicyArgsSpec::to_kopia` never populates them.
714    pub keep_latest: Option<i64>,
715    /// `--keep-hourly`. See [`Self::keep_latest`].
716    pub keep_hourly: Option<i64>,
717    /// `--keep-daily`. See [`Self::keep_latest`].
718    pub keep_daily: Option<i64>,
719    /// `--keep-weekly`. See [`Self::keep_latest`].
720    pub keep_weekly: Option<i64>,
721    /// `--keep-monthly`. See [`Self::keep_latest`].
722    pub keep_monthly: Option<i64>,
723    /// `--keep-annual`. See [`Self::keep_latest`].
724    pub keep_annual: Option<i64>,
725    /// Verbatim extra `policy set` flags (the CRD escape hatch).
726    pub extra_args: Vec<String>,
727}
728
729/// Create-time-fixed repository options applied at `kopia repository create`
730/// (ADR-0005 §13(a)): the encryption/splitter/hash algorithms baked into the repo
731/// format, plus optional Reed-Solomon ECC parity guarding blobs against backend
732/// bit-rot. All fields are immutable post-create (webhook-enforced, §7); kopia only
733/// honors them at create time. Pure args builder so it's unit-testable.
734#[derive(Debug, Clone, Default, PartialEq, Eq)]
735pub struct CreateOptions {
736    /// `--encryption` algorithm (e.g. `AES256-GCM-HMAC-SHA256`).
737    pub encryption: Option<String>,
738    /// `--object-splitter` algorithm.
739    pub splitter: Option<String>,
740    /// `--block-hash` content-hash algorithm.
741    pub hash: Option<String>,
742    /// `--ecc` Reed-Solomon algorithm (e.g. `REED-SOLOMON-CRC32`). ADR-0005 §13(a).
743    pub ecc: Option<String>,
744    /// `--ecc-overhead-percent` parity overhead. ADR-0005 §13(a).
745    pub ecc_overhead_percent: Option<i64>,
746}
747
748impl CreateOptions {
749    /// The create-time `--encryption`/`--object-splitter`/`--block-hash`/`--ecc`/
750    /// `--ecc-overhead-percent` args, in a stable order. Empty when nothing is set.
751    pub fn args(&self) -> Vec<String> {
752        let mut a = Vec::new();
753        if let Some(v) = &self.encryption {
754            a.push("--encryption".into());
755            a.push(v.clone());
756        }
757        if let Some(v) = &self.splitter {
758            a.push("--object-splitter".into());
759            a.push(v.clone());
760        }
761        if let Some(v) = &self.hash {
762            a.push("--block-hash".into());
763            a.push(v.clone());
764        }
765        if let Some(v) = &self.ecc {
766            a.push("--ecc".into());
767            a.push(v.clone());
768        }
769        if let Some(p) = self.ecc_overhead_percent {
770            a.push("--ecc-overhead-percent".into());
771            a.push(p.to_string());
772        }
773        a
774    }
775}
776
777/// Repository throttling limits applied via `kopia repository throttle set`
778/// (ADR-0005 §13(e)). Each `None` leaves kopia's current value untouched. Pure args
779/// builder so it's unit-testable; an all-`None` instance yields no flags.
780#[derive(Debug, Clone, Default, PartialEq, Eq)]
781pub struct ThrottleArgs {
782    /// `--upload-bytes-per-second`.
783    pub upload_bytes_per_second: Option<i64>,
784    /// `--download-bytes-per-second`.
785    pub download_bytes_per_second: Option<i64>,
786    /// `--read-requests-per-second`.
787    pub read_ops_per_second: Option<i64>,
788    /// `--write-requests-per-second`.
789    pub write_ops_per_second: Option<i64>,
790}
791
792impl ThrottleArgs {
793    /// The `--*-per-second` flags for the set limits, in a stable order. Empty when
794    /// nothing is set (the caller then skips the `throttle set` invocation).
795    pub fn args(&self) -> Vec<String> {
796        let mut a = Vec::new();
797        if let Some(v) = self.upload_bytes_per_second {
798            a.push("--upload-bytes-per-second".into());
799            a.push(v.to_string());
800        }
801        if let Some(v) = self.download_bytes_per_second {
802            a.push("--download-bytes-per-second".into());
803            a.push(v.to_string());
804        }
805        if let Some(v) = self.read_ops_per_second {
806            a.push("--read-requests-per-second".into());
807            a.push(v.to_string());
808        }
809        if let Some(v) = self.write_ops_per_second {
810            a.push("--write-requests-per-second".into());
811            a.push(v.to_string());
812        }
813        a
814    }
815
816    /// Whether no limits are set (so `throttle set` is skipped).
817    pub fn is_empty(&self) -> bool {
818        self.args().is_empty()
819    }
820}
821
822/// Flags for `kopia repository set-parameters`. Modeled on [`ThrottleArgs`]: an all-`None`
823/// builder whose caller skips the invocation entirely when nothing is set.
824///
825/// Durations are pre-rendered **strings** with a unit, not numbers — kopia's
826/// `time.ParseDuration` rejects a bare number (`--epoch-min-duration=3600` →
827/// `time: missing unit in duration "3600"`), so the caller must render them
828/// (`kopiur_api::render_go_duration`) rather than pass user text through.
829#[derive(Debug, Clone, Default, PartialEq, Eq)]
830pub struct SetParametersArgs {
831    /// `--epoch-min-duration` (e.g. `"6h"`).
832    pub epoch_min_duration: Option<String>,
833    /// `--epoch-refresh-frequency` (e.g. `"20m"`).
834    pub epoch_refresh_frequency: Option<String>,
835    /// `--epoch-advance-on-count`.
836    pub epoch_advance_on_count: Option<i64>,
837    /// `--epoch-advance-on-size-mb`. **MiB**, despite the flag name — kopia multiplies by
838    /// 1048576.
839    pub epoch_advance_on_size_mb: Option<i64>,
840    /// `--epoch-checkpoint-frequency`.
841    pub epoch_checkpoint_frequency: Option<i64>,
842    /// `--epoch-delete-parallelism`.
843    pub epoch_delete_parallelism: Option<i64>,
844    /// `--retention-mode`. kopia declares this as an ENUM flag accepting exactly
845    /// `"none"`, `"GOVERNANCE"`, or `"COMPLIANCE"` — note the lowercase `none` against the
846    /// uppercase modes, which is kopia's own inconsistency, not a typo here. Anything else
847    /// is rejected by kopia's flag parser before the command runs.
848    pub retention_mode: Option<String>,
849    /// `--retention-period` (e.g. `"720h"`). Pre-rendered like the epoch durations. kopia's
850    /// own parser also accepts `d`, but kopiur never emits it — `render_go_duration` only
851    /// produces `h`/`m`/`s`. Omitted when `retention_mode` is `"none"`: kopia short-circuits
852    /// the disable path before validating, so a period there is meaningless.
853    pub retention_period: Option<String>,
854}
855
856impl SetParametersArgs {
857    /// The flags for the set parameters, in a stable order. Empty when nothing is set (the
858    /// caller then skips the `set-parameters` invocation).
859    pub fn args(&self) -> Vec<String> {
860        let mut a = Vec::new();
861        if let Some(v) = &self.epoch_min_duration {
862            a.push("--epoch-min-duration".into());
863            a.push(v.clone());
864        }
865        if let Some(v) = &self.epoch_refresh_frequency {
866            a.push("--epoch-refresh-frequency".into());
867            a.push(v.clone());
868        }
869        if let Some(v) = self.epoch_advance_on_count {
870            a.push("--epoch-advance-on-count".into());
871            a.push(v.to_string());
872        }
873        if let Some(v) = self.epoch_advance_on_size_mb {
874            a.push("--epoch-advance-on-size-mb".into());
875            a.push(v.to_string());
876        }
877        if let Some(v) = self.epoch_checkpoint_frequency {
878            a.push("--epoch-checkpoint-frequency".into());
879            a.push(v.to_string());
880        }
881        if let Some(v) = self.epoch_delete_parallelism {
882            a.push("--epoch-delete-parallelism".into());
883            a.push(v.to_string());
884        }
885        // Appended AFTER the epoch flags so the existing positional order assertions in
886        // `tests.rs` keep holding.
887        if let Some(v) = &self.retention_mode {
888            a.push("--retention-mode".into());
889            a.push(v.clone());
890        }
891        if let Some(v) = &self.retention_period {
892            a.push("--retention-period".into());
893            a.push(v.clone());
894        }
895        a
896    }
897
898    /// Whether no parameters are set (so `set-parameters` is skipped).
899    pub fn is_empty(&self) -> bool {
900        self.args().is_empty()
901    }
902}
903
904/// UI authentication mode for `kopia server start`. Controller-agnostic mirror of
905/// the api crate's `ServerAuth` (this crate has no kube dependency).
906#[derive(Debug, Clone, PartialEq, Eq)]
907pub enum ServerAuthMode {
908    /// Require a UI login. `username` goes on argv; the password is supplied
909    /// separately to [`KopiaClient::server_start`] (it is never baked into the pure
910    /// arg builder, nor into a ConfigMap).
911    Password {
912        /// HTTP basic-auth username for the UI (`--server-username`).
913        username: String,
914    },
915    /// No UI authentication (`--without-password`). kopia requires `--insecure`
916    /// alongside it, which [`server_start_args`] always emits.
917    None,
918}
919
920impl ServerAuthMode {
921    /// Stable discriminant for logging.
922    pub fn kind_str(&self) -> &'static str {
923        match self {
924            ServerAuthMode::Password { .. } => "password",
925            ServerAuthMode::None => "none",
926        }
927    }
928}
929
930/// A typed description of how to run `kopia server start` (the web UI).
931///
932/// ## Why this is its own non-returning path (not `run_ok`)
933///
934/// `server start` is a long-running process that never exits on success, so the
935/// `run_ok`/`run_json` "spawn, read to EOF, wait for exit code" pattern would hang
936/// forever. [`KopiaClient::server_start`] instead `exec`s the binary so kopia takes
937/// over this PID and receives `SIGTERM` directly from the kubelet on pod shutdown.
938///
939/// ## TLS and auth
940///
941/// The server always runs with `--insecure` (no in-pod TLS): TLS is terminated by
942/// the user's ingress and the Service speaks plain HTTP. `--insecure` is kopia's
943/// *no-TLS* switch — it is required in every mode, and is **not** the no-auth knob
944/// (that is `--without-password`, selected by [`ServerAuthMode::None`]).
945#[derive(Debug, Clone, PartialEq, Eq)]
946pub struct ServerStartSpec {
947    /// Listen address — must be non-loopback (e.g. `0.0.0.0:51515`) to be reachable
948    /// through a Service.
949    pub address: String,
950    /// UI authentication mode.
951    pub auth: ServerAuthMode,
952    /// Serve the embedded HTML UI (`--ui`). Defaults to enabled.
953    pub ui: bool,
954}
955
956impl Default for ServerStartSpec {
957    fn default() -> Self {
958        Self {
959            address: "0.0.0.0:51515".to_string(),
960            auth: ServerAuthMode::None,
961            ui: true,
962        }
963    }
964}
965
966/// Builder for [`KopiaClient`].
967#[derive(Debug, Clone, Default)]
968pub struct KopiaClientBuilder {
969    binary: Option<PathBuf>,
970    common_env: BTreeMap<String, String>,
971    common_env_remove: BTreeSet<String>,
972    common_args: Vec<String>,
973    default_timeout: Option<Duration>,
974}
975
976impl KopiaClientBuilder {
977    /// Set the path to the kopia binary. Injectable so tests can point at a
978    /// fake shim. Defaults to `kopia` (resolved via `PATH`).
979    pub fn binary(mut self, binary: impl Into<PathBuf>) -> Self {
980        self.binary = Some(binary.into());
981        self
982    }
983
984    /// Add an environment variable applied to every invocation. Use this for
985    /// `KOPIA_PASSWORD`, `KOPIA_CONFIG_PATH`, cache dirs, and S3 credentials.
986    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
987        self.common_env.insert(key.into(), value.into());
988        self
989    }
990
991    /// Record an environment variable to UNSET on every spawned command
992    /// (`Command::env_remove`), mirroring the `None` half of the
993    /// per-invocation overlay semantics (see `run_with_env`: `Some(v)` sets,
994    /// `None` unsets). Use this to keep an ambient/inherited credential from
995    /// leaking into a client that must not see it (e.g. the source
996    /// `KOPIA_PASSWORD` on a destination-configured replication client).
997    /// Applied AFTER [`Self::env`], so a key both set and removed ends up
998    /// unset; a later per-invocation overlay `Some(v)` still wins.
999    pub fn env_remove(mut self, key: impl Into<String>) -> Self {
1000        self.common_env_remove.insert(key.into());
1001        self
1002    }
1003
1004    /// Add a global arg applied (after the subcommand tokens) to every
1005    /// invocation. Must be a flag kopia accepts on *every* subcommand (e.g. a
1006    /// global flag); per-subcommand flags belong on the specific method.
1007    /// Prefer env vars (e.g. `KOPIA_CHECK_FOR_UPDATES=false`) for cross-cutting
1008    /// behavior.
1009    pub fn common_arg(mut self, arg: impl Into<String>) -> Self {
1010        self.common_args.push(arg.into());
1011        self
1012    }
1013
1014    /// Default per-invocation timeout. `None` means no timeout.
1015    pub fn default_timeout(mut self, timeout: Duration) -> Self {
1016        self.default_timeout = Some(timeout);
1017        self
1018    }
1019
1020    /// Finalize.
1021    pub fn build(self) -> KopiaClient {
1022        let mut common_args = self.common_args;
1023        // kopia's hidden default-on `--auto-maintenance` opportunistically runs
1024        // a maintenance pass as a side effect of other commands (`snapshot
1025        // create`/`delete`/`expire`, and — verified against the pinned kopia
1026        // 0.23.1 binary — even a bare `policy set`) whenever the connected
1027        // client identity equals the repository's designated maintenance
1028        // owner. Only the Maintenance CR's own `maintenance run` may trigger
1029        // maintenance (that explicit subcommand is unaffected by this flag —
1030        // also verified against the pinned binary), so every `KopiaClient`
1031        // carries `--no-auto-maintenance` on every invocation, unconditionally,
1032        // rather than relying on each call site to remember it.
1033        common_args.push("--no-auto-maintenance".into());
1034        KopiaClient {
1035            binary: self.binary.unwrap_or_else(|| PathBuf::from("kopia")),
1036            common_env: self.common_env,
1037            common_env_remove: self.common_env_remove,
1038            common_args,
1039            default_timeout: self.default_timeout,
1040        }
1041    }
1042}
1043
1044/// A kopia client backed by the real `kopia` binary via `tokio::process`.
1045///
1046/// Construction is pure — building a client never spawns a process. Only the
1047/// `async` methods invoke `kopia`. The builder defaults the binary to `kopia`
1048/// (resolved via `PATH`); inject a path for tests or non-standard images:
1049///
1050/// ```
1051/// use std::path::PathBuf;
1052/// use kopiur_kopia::KopiaClient;
1053///
1054/// let client = KopiaClient::builder().build();
1055/// assert_eq!(client.binary(), &PathBuf::from("kopia"));
1056///
1057/// let custom = KopiaClient::builder()
1058///     .binary("/usr/local/bin/kopia")
1059///     .env("KOPIA_PASSWORD", "s3cr3t")
1060///     .build();
1061/// assert_eq!(custom.binary(), &PathBuf::from("/usr/local/bin/kopia"));
1062/// ```
1063#[derive(Debug, Clone)]
1064pub struct KopiaClient {
1065    binary: PathBuf,
1066    common_env: BTreeMap<String, String>,
1067    common_env_remove: BTreeSet<String>,
1068    common_args: Vec<String>,
1069    default_timeout: Option<Duration>,
1070}
1071
1072/// Fallback budget for kopia's finalize step after a stdin-fed snapshot's writer
1073/// closes, when neither the caller nor the client supplies one.
1074///
1075/// Generous on purpose — at EOF a `--stdin-file` snapshot has already streamed
1076/// almost all of its content blobs, so the remaining work is the last blob plus
1077/// the manifest, and a slow object store can still make that take minutes. The
1078/// value is not a performance knob; it is the difference between "this run fails
1079/// with a Timeout" and "this mover Job never ends".
1080pub const DEFAULT_STDIN_FINALIZE_TIMEOUT: Duration = Duration::from_secs(3600);
1081
1082/// Grace a caller should add on top of a producer's own timeout when asking
1083/// [`KopiaClient::snapshot_create_stdin_outcome_with`] to bound kopia's finalize.
1084///
1085/// The producer phase is already bounded by the work spec's
1086/// `workloadExec.timeout`; this covers the phase AFTER it, which no existing
1087/// timeout owned.
1088pub const STDIN_FINALIZE_GRACE: Duration = Duration::from_secs(600);
1089
1090/// SIGKILL a timed-out kopia child AND reap it before returning
1091/// (`Child::kill()` = `start_kill` + `wait`). Without the wait the killed
1092/// child lingers as a zombie until tokio's SIGCHLD-driven orphan reaper gets
1093/// to it — best-effort and non-deterministic; with the controller's 120s
1094/// `default_timeout`, a hung backend would leave one transient zombie per
1095/// retry. Reaping inline makes cleanup a guarantee instead of a race.
1096/// Which half of the streaming copy failed, so the error can name the right thing.
1097///
1098/// `tokio::join!`ing the sink write, kopia's stderr read and kopia's exit into one
1099/// `io::Error` loses the only fact that matters for diagnosis: whose pipe broke. A
1100/// `streamExec` restore whose consumer died reported "failed to spawn kopia binary
1101/// `kopia`: broken pipe" — pointing at a binary that had spawned and was streaming.
1102enum StreamHalves {
1103    /// Writing kopia's stdout to the CONSUMER failed.
1104    Sink(std::io::Error),
1105    /// Reading kopia's stderr, or reaping kopia, failed.
1106    Kopia(std::io::Error),
1107}
1108
1109impl From<std::io::Error> for StreamHalves {
1110    fn from(e: std::io::Error) -> Self {
1111        // Only the `?`s inside the join arm produce this, and both are kopia-side.
1112        StreamHalves::Kopia(e)
1113    }
1114}
1115
1116impl StreamHalves {
1117    fn into_kopia_error(self, binary: &std::path::Path, args: &str) -> KopiaError {
1118        match self {
1119            StreamHalves::Sink(source) => KopiaError::OutputSink {
1120                args: args.to_string(),
1121                source,
1122            },
1123            StreamHalves::Kopia(source) => KopiaError::Spawn {
1124                binary: binary.display().to_string(),
1125                source,
1126            },
1127        }
1128    }
1129}
1130
1131/// Best-effort on error: nothing here can improve on the Timeout being
1132/// returned, so a kill/wait failure is only logged.
1133async fn kill_and_reap(child: &mut tokio::process::Child) {
1134    if let Err(e) = child.kill().await {
1135        tracing::debug!(error = %e, "could not kill/reap a timed-out kopia child");
1136    }
1137}
1138
1139/// The raw outcome of running a kopia subprocess.
1140/// What a stdin producer decided, handed back from the `feed` closure of
1141/// [`KopiaClient::snapshot_create_stdin_outcome_with`].
1142///
1143/// An enum rather than a `bool` because the two arms are not "success/failure" — they
1144/// are two different LIFECYCLE instructions for a live child process, and getting them
1145/// backwards silently commits a truncated backup. Naming them forces the caller to say
1146/// which one it means.
1147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1148pub enum StdinOutcome {
1149    /// The producer finished successfully, having written `bytes` to the writer.
1150    /// Let kopia see EOF, finalize the snapshot, and exit.
1151    ///
1152    /// The count is not telemetry: `bytes == 0` is treated as a FAILURE by the
1153    /// runner (see [`KopiaError::StdinProducerWroteNothing`]). Carrying it here
1154    /// rather than leaving each producer to check for itself keeps that decision
1155    /// in one place, which is the same reason this is an enum and not a `bool`.
1156    Commit {
1157        /// Bytes the producer moved into kopia's stdin.
1158        bytes: u64,
1159    },
1160    /// The producer did not finish successfully. Kill kopia with stdin still open so
1161    /// it never writes a manifest — no partial snapshot is left behind.
1162    Abort,
1163}
1164
1165/// The writer a stdin producer is handed: kopia's stdin, with the ability to
1166/// CLOSE it removed.
1167///
1168/// The runner's ownership of that pipe IS the safety property. kopia finalizes
1169/// the snapshot when — and only when — stdin reaches EOF, so whoever can close
1170/// the pipe decides whether the snapshot commits. A producer that called
1171/// `shutdown()` on a bare `ChildStdin` would hand kopia EOF while the runner
1172/// still believed it held the commit point, and a subsequent
1173/// [`StdinOutcome::Abort`] would then arrive too late: the kill would land on a
1174/// kopia that had already written a manifest for a TRUNCATED dump.
1175///
1176/// No producer in this repository does that. This type means none ever can —
1177/// which is the difference between a bad state being untaken and being
1178/// unrepresentable.
1179///
1180/// [`tokio::io::AsyncWrite`] is implemented so `tokio::io::copy` still works (the
1181/// pipe-to-pipe transfer must stay constant-memory and must never buffer the
1182/// user's dump), but `poll_shutdown` is a deliberate NO-OP: it reports success
1183/// without touching the pipe, so `AsyncWriteExt::shutdown()` cannot reach it
1184/// either.
1185pub struct StdinWriter<'a>(&'a mut tokio::process::ChildStdin);
1186
1187impl tokio::io::AsyncWrite for StdinWriter<'_> {
1188    fn poll_write(
1189        mut self: std::pin::Pin<&mut Self>,
1190        cx: &mut std::task::Context<'_>,
1191        buf: &[u8],
1192    ) -> std::task::Poll<std::io::Result<usize>> {
1193        std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
1194    }
1195
1196    fn poll_flush(
1197        mut self: std::pin::Pin<&mut Self>,
1198        cx: &mut std::task::Context<'_>,
1199    ) -> std::task::Poll<std::io::Result<()>> {
1200        std::pin::Pin::new(&mut *self.0).poll_flush(cx)
1201    }
1202
1203    /// Deliberately does NOT close the pipe. Closing it is the COMMIT decision,
1204    /// and it belongs to the runner alone — see the type's own documentation.
1205    /// Reports success so a producer that flushes-and-shuts-down (the idiomatic
1206    /// end of a `tokio::io::copy` pipeline) is not made to look like a failure.
1207    fn poll_shutdown(
1208        self: std::pin::Pin<&mut Self>,
1209        _cx: &mut std::task::Context<'_>,
1210    ) -> std::task::Poll<std::io::Result<()>> {
1211        std::task::Poll::Ready(Ok(()))
1212    }
1213}
1214
1215/// What the runner does with a `feed` result: commit the snapshot, or abort it.
1216///
1217/// Separated from [`StdinOutcome`] because they answer different questions.
1218/// `StdinOutcome` is what the PRODUCER reports; this is what the RUNNER decides,
1219/// and the two differ in exactly one case that matters — a producer that reported
1220/// success having written zero bytes.
1221#[derive(Debug)]
1222enum FedVerdict {
1223    /// Close the writer and let kopia finalize.
1224    Commit {
1225        /// Bytes the producer moved (logged; the emptiness decision already happened).
1226        bytes: u64,
1227    },
1228    /// Kill kopia with stdin still open, then fail with this reason's error.
1229    Abort(AbortReason),
1230}
1231
1232/// Why a stdin-fed run is being aborted. Carries everything needed to build the
1233/// error EXCEPT kopia's stderr, which is only available after the kill.
1234#[derive(Debug)]
1235enum AbortReason {
1236    /// The producer reported failure ([`StdinOutcome::Abort`]).
1237    ProducerFailed,
1238    /// The producer reported success but wrote NOTHING.
1239    WroteNothing,
1240    /// The producer's own error, returned verbatim.
1241    ProducerError(KopiaError),
1242}
1243
1244impl AbortReason {
1245    /// The error this abort returns, given the drained kopia stderr.
1246    fn into_error(self, args: String, stderr: &str) -> KopiaError {
1247        match self {
1248            AbortReason::ProducerFailed => KopiaError::StdinProducerFailed {
1249                detail: "the stdin producer failed, so nothing was saved".to_string(),
1250                stderr_tail: tail_lines(stderr),
1251            },
1252            AbortReason::WroteNothing => KopiaError::StdinProducerWroteNothing {
1253                args,
1254                stderr_tail: tail_lines(stderr),
1255            },
1256            // The producer's error is the diagnosis; kopia's stderr would only
1257            // describe the kill we just performed.
1258            AbortReason::ProducerError(e) => e,
1259        }
1260    }
1261}
1262
1263/// Turn a `feed` result into the runner's lifecycle decision. **Pure** and
1264/// exhaustive, so the "an empty dump is a failure" rule lives in exactly one
1265/// place and is unit-testable without spawning kopia.
1266///
1267/// The zero-byte arm is the deliberate one. A producer that exited 0 having
1268/// written nothing almost certainly failed, and its exit status cannot be trusted
1269/// to say so: `pg_dump` exits 0 against an instance whose credentials see no
1270/// databases, `mysqldump` exits 0 with empty grants, and `sh -c 'a | b'` reports
1271/// only `b`'s status. Committing would put a zero-byte "restore point" into the
1272/// repository that retention keeps and a restore would write over a live database
1273/// with.
1274fn classify_fed(fed: Result<StdinOutcome, KopiaError>) -> FedVerdict {
1275    match fed {
1276        Ok(StdinOutcome::Commit { bytes: 0 }) => FedVerdict::Abort(AbortReason::WroteNothing),
1277        Ok(StdinOutcome::Commit { bytes }) => FedVerdict::Commit { bytes },
1278        Ok(StdinOutcome::Abort) => FedVerdict::Abort(AbortReason::ProducerFailed),
1279        Err(e) => FedVerdict::Abort(AbortReason::ProducerError(e)),
1280    }
1281}
1282
1283/// The background stdout/stderr drains for a stdin-fed run.
1284///
1285/// Spawned as TASKS, not awaited alongside `feed` in a `join!`. Draining to EOF
1286/// only completes when kopia EXITS, and kopia cannot exit until stdin closes —
1287/// which happens after `feed` returns. Awaiting both together therefore
1288/// deadlocks: `feed` finishes writing, the drain waits for an exit that waits for
1289/// the close that waits for the drain. Spawning keeps the pipes flowing (so
1290/// kopia's progress chatter never blocks its stdin read) while leaving the
1291/// close/kill decision to the runner.
1292struct StdioDrains {
1293    out: tokio::task::JoinHandle<String>,
1294    err: tokio::task::JoinHandle<String>,
1295}
1296
1297impl StdioDrains {
1298    /// Take `child`'s stdout/stderr pipes and start draining both.
1299    fn spawn(child: &mut tokio::process::Child) -> Self {
1300        fn drain(pipe: Option<impl AsyncReadExt + Unpin + Send + 'static>) -> JoinHandle<String> {
1301            tokio::spawn(async move {
1302                let mut buf = String::new();
1303                if let Some(mut p) = pipe {
1304                    let _ = p.read_to_string(&mut buf).await;
1305                }
1306                buf
1307            })
1308        }
1309        Self {
1310            out: drain(child.stdout.take()),
1311            err: drain(child.stderr.take()),
1312        }
1313    }
1314
1315    /// Await both drains, as `(stdout, stderr)`. A panicked drain yields an empty
1316    /// string rather than failing the run: the run's own outcome is authoritative,
1317    /// and losing a diagnostic must never turn a committed snapshot into an error.
1318    async fn join(self) -> (String, String) {
1319        let (out, err) = tokio::join!(self.out, self.err);
1320        (out.unwrap_or_default(), err.unwrap_or_default())
1321    }
1322}
1323
1324/// Everything a stdin-fed snapshot needs except the producer itself.
1325///
1326/// A named struct rather than six positional parameters, for one specific
1327/// reason beyond the argument count: `source_path` and `stdin_file` are both
1328/// `&str` and are trivially transposable at a call site. Swapping them would
1329/// record the snapshot under the FILE name as its kopia source path — a silent
1330/// identity change, which orphans the whole history for that source and is
1331/// exactly the class of mistake this repo builds types to prevent.
1332#[derive(Debug, Clone)]
1333pub struct StdinSnapshot<'a> {
1334    /// The VIRTUAL root kopia records the artifact under, e.g.
1335    /// `/stream/postgres.sql`. Nothing is read from the pod's filesystem.
1336    pub source_path: &'a str,
1337    /// The single virtual file name stored inside that root, e.g.
1338    /// `postgres.sql`. Becomes kopia's `--stdin-file`.
1339    pub stdin_file: &'a str,
1340    /// Tags to stamp on the manifest.
1341    pub tags: &'a BTreeMap<String, String>,
1342    /// `username@hostname:path` identity to record, overriding the mover pod's
1343    /// ambient user/host.
1344    pub override_source: Option<&'a str>,
1345    /// `snapshot create` argv options.
1346    pub opts: &'a SnapshotCreateOptions,
1347    /// How long kopia may take to FINALIZE after the writer closes. `None` falls
1348    /// back to the client's `default_timeout`, then
1349    /// [`DEFAULT_STDIN_FINALIZE_TIMEOUT`] — never unbounded. Callers derive it
1350    /// from the producer's own timeout plus [`STDIN_FINALIZE_GRACE`].
1351    pub finalize_timeout: Option<Duration>,
1352}
1353
1354struct RawOutput {
1355    code: Option<i32>,
1356    stdout: String,
1357    stderr: String,
1358}
1359
1360impl KopiaClient {
1361    /// Start building a client.
1362    pub fn builder() -> KopiaClientBuilder {
1363        KopiaClientBuilder::default()
1364    }
1365
1366    /// The configured binary path (useful for diagnostics / tests).
1367    pub fn binary(&self) -> &PathBuf {
1368        &self.binary
1369    }
1370
1371    /// The environment applied to every invocation (useful for tests asserting
1372    /// that the cache/log/config dirs were injected).
1373    pub fn common_env(&self) -> &BTreeMap<String, String> {
1374        &self.common_env
1375    }
1376
1377    /// The environment-variable names UNSET on every invocation (useful for
1378    /// tests asserting a credential is structurally kept away from a client).
1379    pub fn common_env_remove(&self) -> &BTreeSet<String> {
1380        &self.common_env_remove
1381    }
1382
1383    /// The global args appended after the subcommand on every invocation
1384    /// (useful for tests asserting e.g. `--no-auto-maintenance` is always
1385    /// present).
1386    pub fn common_args(&self) -> &[String] {
1387        &self.common_args
1388    }
1389
1390    /// The timeout applied to every invocation when set (useful for tests
1391    /// asserting a caller time-bounds its subprocesses).
1392    pub fn default_timeout(&self) -> Option<Duration> {
1393        self.default_timeout
1394    }
1395
1396    /// Run kopia with the given subcommand args, returning raw output. Applies
1397    /// `common_env` and inserts `common_args` immediately after the subcommand,
1398    /// plus a per-invocation environment overlay (`Some(value)` sets a variable,
1399    /// `None` **unsets** an otherwise-inherited one — pass an empty map for the
1400    /// common case). stdout and stderr are fully captured. Honors the default
1401    /// timeout if set. Used by the replication mover
1402    /// to point `kopia repository sync-to` at the *destination* backend's
1403    /// credentials (remapped from their `KOPIUR_DEST_`-prefixed copies) while
1404    /// clearing any source credential the destination does not set, so a stale
1405    /// source `AWS_SESSION_TOKEN` (etc.) cannot leak into the destination auth.
1406    /// The `kopia` [`Command`] with this client's environment and common args
1407    /// applied, and NO stdio configured yet — every runner sets its own.
1408    ///
1409    /// Extracted so the streaming runners share exactly the environment the
1410    /// ordinary one uses: a stdin-fed snapshot that silently missed
1411    /// `common_env`/`common_args` would connect to a different repository, or skip
1412    /// `--no-check-for-updates`, in a way no test would obviously catch.
1413    fn base_command(&self, args: &[String]) -> Command {
1414        let mut cmd = Command::new(&self.binary);
1415        // Do not inherit the ambient environment's KOPIA_* unless the caller
1416        // set it explicitly; but we *do* inherit PATH etc. by default, which is
1417        // fine. We only override what common_env specifies.
1418        for (k, v) in &self.common_env {
1419            cmd.env(k, v);
1420        }
1421        // Builder-recorded unsets (env_remove) are applied after common_env,
1422        // so a key both set and removed ends up unset.
1423        for k in &self.common_env_remove {
1424            cmd.env_remove(k);
1425        }
1426        cmd.args(args);
1427        // Append common args (e.g. --no-check-for-updates) after the subcommand
1428        // tokens the caller passed.
1429        cmd.args(&self.common_args);
1430        cmd
1431    }
1432
1433    /// Spawn `cmd`, retrying the transient fork/exec errnos.
1434    ///
1435    /// ETXTBSY (26) and EAGAIN (11) are not "the binary is wrong" failures —
1436    /// they are races: ETXTBSY appears when another thread in a multithreaded
1437    /// process forks-for-exec while the target file still has a writable fd open
1438    /// elsewhere (the classic fork/exec race), and EAGAIN appears under fork
1439    /// pressure on a busy node. A real bad-binary error (ENOENT, EACCES) is
1440    /// returned immediately. Retries are quick and capped.
1441    ///
1442    /// Shared by EVERY runner. The stdin and raw-streaming runners each carried
1443    /// their own copy (or, in the stdin runner's case, no retry at all), which is
1444    /// exactly the kind of divergence that shows up as a rare parallel-test flake
1445    /// on one machine and nowhere else.
1446    async fn spawn_with_retry(
1447        &self,
1448        cmd: &mut Command,
1449    ) -> Result<tokio::process::Child, KopiaError> {
1450        let mut attempt = 0u32;
1451        loop {
1452            match cmd.spawn() {
1453                Ok(c) => return Ok(c),
1454                Err(e) if matches!(e.raw_os_error(), Some(26) | Some(11)) && attempt < 10 => {
1455                    attempt += 1;
1456                    tokio::time::sleep(Duration::from_millis(5)).await;
1457                }
1458                Err(source) => {
1459                    return Err(KopiaError::Spawn {
1460                        binary: self.binary.display().to_string(),
1461                        source,
1462                    });
1463                }
1464            }
1465        }
1466    }
1467
1468    /// How long kopia gets to finalize after a stdin-fed snapshot's writer closes.
1469    ///
1470    /// `requested` (the caller's budget, derived from the producer's own timeout)
1471    /// wins; then this client's [`default_timeout`](Self::default_timeout); then
1472    /// [`DEFAULT_STDIN_FINALIZE_TIMEOUT`]. Never unbounded: the point is that a
1473    /// kopia wedged writing a manifest cannot pin the mover Job forever.
1474    fn finalize_budget(&self, requested: Option<Duration>) -> Duration {
1475        requested
1476            .or(self.default_timeout)
1477            .unwrap_or(DEFAULT_STDIN_FINALIZE_TIMEOUT)
1478    }
1479
1480    /// Test-only accessor for [`Self::finalize_budget`] — the budget is the
1481    /// difference between "this run fails with a Timeout" and "this mover Job
1482    /// never ends", so it is asserted directly rather than inferred.
1483    #[cfg(test)]
1484    pub(crate) fn finalize_budget_for_test(&self, requested: Option<Duration>) -> Duration {
1485        self.finalize_budget(requested)
1486    }
1487
1488    async fn run_with_env(
1489        &self,
1490        args: &[String],
1491        env_overlay: &BTreeMap<String, Option<String>>,
1492    ) -> Result<RawOutput, KopiaError> {
1493        let display_args = args.join(" ");
1494        let mut cmd = self.base_command(args);
1495        // Per-invocation overlay wins over both the inherited env and common_env.
1496        for (k, v) in env_overlay {
1497            match v {
1498                Some(value) => cmd.env(k, value),
1499                None => cmd.env_remove(k),
1500            };
1501        }
1502        cmd.stdin(Stdio::null());
1503        cmd.stdout(Stdio::piped());
1504        cmd.stderr(Stdio::piped());
1505
1506        let mut child = self.spawn_with_retry(&mut cmd).await?;
1507
1508        // Take the pipes so we can read both concurrently without deadlocking
1509        // on a full pipe buffer.
1510        let mut stdout_pipe = child.stdout.take().expect("stdout piped");
1511        let stderr_pipe = child.stderr.take().expect("stderr piped");
1512
1513        let read_out = async {
1514            let mut buf = String::new();
1515            stdout_pipe.read_to_string(&mut buf).await.map(|_| buf)
1516        };
1517        let read_err = async {
1518            // Stream kopia's stderr line-by-line so its real progress and log
1519            // output is visible in `kubectl logs` (at debug, target `kopia`) for
1520            // both the controller's short ops and the long-running mover Job —
1521            // while still accumulating the full text byte-for-byte for the
1522            // failure tail carried by `KopiaError::NonZeroExit`.
1523            let mut reader = BufReader::new(stderr_pipe);
1524            let mut buf = String::new();
1525            let mut line = String::new();
1526            loop {
1527                line.clear();
1528                match reader.read_line(&mut line).await {
1529                    Ok(0) => break,
1530                    Ok(_) => {
1531                        let trimmed = line.trim_end_matches(['\n', '\r']);
1532                        if !trimmed.is_empty() {
1533                            tracing::debug!(target: "kopia", "{trimmed}");
1534                        }
1535                        buf.push_str(&line);
1536                    }
1537                    Err(e) => return Err(e),
1538                }
1539            }
1540            Ok(buf)
1541        };
1542
1543        let wait_with_io = async {
1544            let (out, err, status) = tokio::join!(read_out, read_err, child.wait());
1545            Ok::<_, std::io::Error>((out?, err?, status?))
1546        };
1547
1548        let (stdout, stderr, status) = match self.default_timeout {
1549            Some(t) => match tokio::time::timeout(t, wait_with_io).await {
1550                Ok(res) => res.map_err(|source| KopiaError::Spawn {
1551                    binary: self.binary.display().to_string(),
1552                    source,
1553                })?,
1554                Err(_) => {
1555                    kill_and_reap(&mut child).await;
1556                    return Err(KopiaError::Timeout {
1557                        args: display_args,
1558                        seconds: t.as_secs(),
1559                    });
1560                }
1561            },
1562            None => wait_with_io.await.map_err(|source| KopiaError::Spawn {
1563                binary: self.binary.display().to_string(),
1564                source,
1565            })?,
1566        };
1567
1568        Ok(RawOutput {
1569            code: status.code(),
1570            stdout,
1571            stderr,
1572        })
1573    }
1574
1575    /// Run kopia and require a zero exit code, returning stdout. On a non-zero
1576    /// exit, builds a structured [`KopiaError::NonZeroExit`] with the stderr
1577    /// tail and a best-effort error class.
1578    async fn run_ok(&self, args: &[String]) -> Result<String, KopiaError> {
1579        self.run_ok_with_env(args, &BTreeMap::new()).await
1580    }
1581
1582    /// [`Self::run_ok`] with a per-invocation environment overlay (see
1583    /// [`Self::run_with_env`]).
1584    async fn run_ok_with_env(
1585        &self,
1586        args: &[String],
1587        env_overlay: &BTreeMap<String, Option<String>>,
1588    ) -> Result<String, KopiaError> {
1589        self.run_ok_full(args, env_overlay).await.map(|o| o.stdout)
1590    }
1591
1592    /// [`Self::run_ok_with_env`] keeping **stderr on success**.
1593    ///
1594    /// The plain `run_ok` shape — `Ok(out.stdout)`, dropping `out.stderr` —
1595    /// made a whole class of failure undiagnosable: kopia can exit **0**,
1596    /// print nothing on stdout, and explain itself only on stderr. That is
1597    /// exactly what `snapshot create` does when its retention policy has
1598    /// `ignoreIdenticalSnapshots` on and nothing changed, and it surfaced as
1599    /// `no JSON output found on stdout (class Unknown)` with no reason
1600    /// attached anywhere (#351).
1601    ///
1602    /// Callers that genuinely only want stdout keep using `run_ok`; the two
1603    /// that need to reason about a silent success use this.
1604    /// Run kopia with its stdin PIPED, handing the writer to `feed`.
1605    ///
1606    /// The whole point is that `feed` decides whether the run commits: kopia only
1607    /// finalizes when stdin hits EOF, so returning [`StdinOutcome::Commit`] (having
1608    /// dropped the writer) lets it finish, while [`StdinOutcome::Abort`] kills it
1609    /// with stdin still open so it writes nothing. See
1610    /// [`Self::snapshot_create_stdin_outcome_with`] for why that matters.
1611    ///
1612    /// stdout/stderr are captured as usual. `feed`'s own error is returned verbatim,
1613    /// after the child has been killed and reaped.
1614    async fn run_with_stdin<F>(
1615        &self,
1616        args: &[String],
1617        finalize_timeout: Option<Duration>,
1618        feed: F,
1619    ) -> Result<RawOutput, KopiaError>
1620    where
1621        F: AsyncFnOnce(&mut StdinWriter<'_>) -> Result<StdinOutcome, KopiaError>,
1622    {
1623        let display_args = args.join(" ");
1624        let mut cmd = self.base_command(args);
1625        cmd.stdin(Stdio::piped());
1626        cmd.stdout(Stdio::piped());
1627        cmd.stderr(Stdio::piped());
1628        let mut child = self.spawn_with_retry(&mut cmd).await?;
1629        // The RUNNER owns the writer, and `feed` only borrows it. That ownership is
1630        // load-bearing, not a style choice: if `feed` owned it, the writer would be
1631        // dropped when `feed`'s future completes — closing the pipe, giving kopia EOF,
1632        // and letting it COMMIT the snapshot before we ever got to inspect the
1633        // producer's outcome. Keeping it here is what makes `Abort` able to kill kopia
1634        // with stdin still open. (Caught by
1635        // `integration_stdin::abort_after_full_payload_leaves_no_snapshot`.)
1636        //
1637        // `kill_and_reap` FIRST on the missing-pipe path: returning there with a live
1638        // child and no writer leaves a kopia blocked forever on a `read(2)` of a pipe
1639        // nobody will ever write to or close, holding a repository session open, until
1640        // the mover pod is evicted. A bare `?` on a `take()` is a zombie leak.
1641        let mut stdin = match child.stdin.take() {
1642            Some(pipe) => pipe,
1643            None => {
1644                kill_and_reap(&mut child).await;
1645                return Err(KopiaError::Spawn {
1646                    binary: self.binary.display().to_string(),
1647                    source: std::io::Error::other("kopia stdin pipe was not created"),
1648                });
1649            }
1650        };
1651        let drains = StdioDrains::spawn(&mut child);
1652
1653        // `feed` gets a `StdinWriter`, not the `ChildStdin` itself: it may write and
1654        // flush, but it CANNOT close the pipe, which is what keeps the commit
1655        // decision here. The borrow ends when `feed` returns, so the abort arms can
1656        // still kill the child while `stdin` is alive and drop it afterwards.
1657        let bytes = match classify_fed(feed(&mut StdinWriter(&mut stdin)).await) {
1658            // ORDER IS THE SAFETY PROPERTY: kill the child while `stdin` is still
1659            // alive, so kopia never reaches EOF and never writes a manifest; drop
1660            // the writer only afterwards. Reversing the two would let kopia commit
1661            // the partial snapshot in the window before the signal lands.
1662            FedVerdict::Abort(reason) => {
1663                kill_and_reap(&mut child).await;
1664                drop(stdin);
1665                let stderr = drains.join().await.1;
1666                return Err(reason.into_error(display_args, &stderr));
1667            }
1668            FedVerdict::Commit { bytes } => bytes,
1669        };
1670
1671        // Close the writer NOW — this is the commit point. kopia sees EOF and
1672        // finalizes the manifest.
1673        drop(stdin);
1674        // BOUNDED (#451). This wait used to be `child.wait()` with no timeout at
1675        // all, so a kopia wedged finalizing — a hung object-store PUT of the last
1676        // content blob, a stalled manifest write — pinned the mover Job forever,
1677        // outside any `activeDeadlineSeconds` accounting and with no status to
1678        // explain it.
1679        //
1680        // A timeout here is honestly ambiguous: the snapshot MAY have committed
1681        // before we gave up. That is why it surfaces as an error and never as
1682        // success — this CR must not claim a manifest it cannot prove exists.
1683        // WHERE DISCOVERY IS ENABLED on the repository, a manifest that did land
1684        // is picked up by the catalog scanner as a foreign snapshot and becomes
1685        // governable. Where it is not, it is orphaned outside every retention
1686        // path (GFS is driven off `Snapshot` CRs) until a full maintenance pass
1687        // or a manual `kopia snapshot delete`. The budget makes this rare, not
1688        // impossible.
1689        let budget = self.finalize_budget(finalize_timeout);
1690        let status = match tokio::time::timeout(budget, child.wait()).await {
1691            Ok(res) => res.map_err(|source| KopiaError::Spawn {
1692                binary: self.binary.display().to_string(),
1693                source,
1694            })?,
1695            Err(_) => {
1696                kill_and_reap(&mut child).await;
1697                drains.join().await;
1698                return Err(KopiaError::Timeout {
1699                    args: display_args,
1700                    seconds: budget.as_secs(),
1701                });
1702            }
1703        };
1704        tracing::debug!(
1705            bytes,
1706            "stdin producer committed; kopia finalized the snapshot"
1707        );
1708        let (stdout, stderr) = drains.join().await;
1709        Ok(RawOutput {
1710            code: status.code(),
1711            stdout,
1712            stderr,
1713        })
1714    }
1715
1716    async fn run_ok_full(
1717        &self,
1718        args: &[String],
1719        env_overlay: &BTreeMap<String, Option<String>>,
1720    ) -> Result<RawOutput, KopiaError> {
1721        let out = self.run_with_env(args, env_overlay).await?;
1722        if out.code == Some(0) {
1723            Ok(out)
1724        } else {
1725            Err(KopiaError::NonZeroExit {
1726                args: args.join(" "),
1727                code: out.code,
1728                class: KopiaErrorClass::classify(&out.stderr),
1729                stderr_tail: tail_lines(&out.stderr),
1730            })
1731        }
1732    }
1733
1734    /// Run kopia, require success, and parse the trailing JSON value on stdout
1735    /// into `T`. Kopia prints the result as the *last* JSON value on stdout
1736    /// (progress goes to stderr), so we parse from the first `{`/`[`.
1737    ///
1738    /// An empty stdout carries kopia's stderr tail into the error, so
1739    /// "kopia said nothing" is never again indistinguishable from "kopia said
1740    /// why, and we threw it away" (#351).
1741    async fn run_json<T: DeserializeOwned>(
1742        &self,
1743        args: &[String],
1744        context: &str,
1745    ) -> Result<T, KopiaError> {
1746        let out = self.run_ok_full(args, &BTreeMap::new()).await?;
1747        let json = extract_json(&out.stdout).ok_or_else(|| KopiaError::EmptyOutput {
1748            context: context.to_string(),
1749            stderr_tail: tail_lines(&out.stderr),
1750        })?;
1751        serde_json::from_str::<T>(json).map_err(|source| KopiaError::Json {
1752            context: context.to_string(),
1753            source,
1754        })
1755    }
1756
1757    /// Connect to an existing repository (`kopia repository connect <backend>`).
1758    /// `cache` sizes this connection's local kopia cache; pass
1759    /// [`CacheTuning::default`] to leave kopia's defaults.
1760    pub async fn repository_connect(
1761        &self,
1762        spec: &ConnectSpec,
1763        cache: CacheTuning,
1764    ) -> Result<(), KopiaError> {
1765        self.repository_connect_with(spec, cache, ConnectOptions::default())
1766            .await
1767    }
1768
1769    /// Connect to an existing repository **read-only** (`kopia repository
1770    /// connect <backend> --readonly`). The read-only bit persists in kopia's
1771    /// client config, so every subsequent invocation on this connection is
1772    /// structurally unable to mutate the repository — the connect mode for
1773    /// browse sessions. Every other mover flow stays on the read-write
1774    /// [`Self::repository_connect`].
1775    pub async fn repository_connect_readonly(
1776        &self,
1777        spec: &ConnectSpec,
1778        cache: CacheTuning,
1779    ) -> Result<(), KopiaError> {
1780        self.repository_connect_with(
1781            spec,
1782            cache,
1783            ConnectOptions {
1784                readonly: true,
1785                persist_credentials: false,
1786            },
1787        )
1788        .await
1789    }
1790
1791    /// Connect to an existing repository with explicit [`ConnectOptions`]
1792    /// (`kopia repository connect <backend> [--readonly]
1793    /// [--persist-credentials]`). [`Self::repository_connect`] and
1794    /// [`Self::repository_connect_readonly`] both delegate here with the
1795    /// options that reproduce their historical argv byte-for-byte. The
1796    /// replication mover's source connect uses `readonly` +
1797    /// `persist_credentials` together so `snapshot migrate --source-config`
1798    /// can later read the source password from the persisted credentials.
1799    pub async fn repository_connect_with(
1800        &self,
1801        spec: &ConnectSpec,
1802        cache: CacheTuning,
1803        opts: ConnectOptions,
1804    ) -> Result<(), KopiaError> {
1805        self.run_ok(&connect_args(spec, cache, opts))
1806            .await
1807            .map(|_| ())
1808    }
1809
1810    /// Create a new repository (`kopia repository create <backend>`). `cache` sizes
1811    /// the creating connection's local cache; pass [`CacheTuning::default`] to leave
1812    /// kopia's defaults. `create_opts` carries the create-time-fixed knobs
1813    /// (encryption/splitter/hash algorithms, ECC) baked into the repository format.
1814    pub async fn repository_create(
1815        &self,
1816        spec: &ConnectSpec,
1817        cache: CacheTuning,
1818        create_opts: &CreateOptions,
1819    ) -> Result<(), KopiaError> {
1820        let mut args = vec!["repository".into(), "create".into()];
1821        args.extend(spec.backend_args());
1822        args.extend(cache.args());
1823        args.extend(create_opts.args());
1824        self.run_ok(&args).await.map(|_| ())
1825    }
1826
1827    /// Set the repository's throttling limits (`kopia repository throttle set`).
1828    /// Caps upload/download bytes-per-sec and read/list/upload ops-per-sec so a run
1829    /// doesn't saturate a link or hammer an object store (ADR-0005 §13(e)). A no-op
1830    /// (skips the call) when nothing is set.
1831    pub async fn repository_throttle_set(&self, throttle: &ThrottleArgs) -> Result<(), KopiaError> {
1832        let flags = throttle.args();
1833        if flags.is_empty() {
1834            return Ok(());
1835        }
1836        let mut args = vec!["repository".into(), "throttle".into(), "set".into()];
1837        args.extend(flags);
1838        self.run_ok(&args).await.map(|_| ())
1839    }
1840
1841    /// Flip the CONNECTED repository's client-side read-only bit
1842    /// (`kopia repository set-client --read-only` / `--read-write`), issue #374.
1843    ///
1844    /// Three properties, all pinned against kopia 0.23.1 by
1845    /// `crates/kopia/tests/integration_set_client_throttle.rs`:
1846    ///
1847    /// - **Works on a read-only connection.** `set-client` registers kopia's
1848    ///   `repositoryReaderAction`, so it opens fine against a config connected with
1849    ///   `--readonly` — unlike [`Self::repository_set_parameters`], which needs a real
1850    ///   blob write and hard-errors there.
1851    /// - **The bit lives in the local config, not the repository.** `read_only: true`
1852    ///   writes `"readonly": true` into the `KOPIA_CONFIG_PATH` JSON; `false` removes the
1853    ///   key. The test fingerprints every blob in the backend across the flip and requires
1854    ///   it unchanged, so this never mutates shared state and needs no maintenance
1855    ///   ownership/lease.
1856    /// - **It is the "flip window" primitive** for a read-only-connected config that must
1857    ///   run a write-requiring verb: flip read-write, run the verb, flip back. Note that
1858    ///   [`Self::repository_throttle_set`] does *not* need this window — M3a measured it
1859    ///   succeeding directly on a `--readonly` connection, and leaving the read-only bit
1860    ///   intact, because it only rewrites the local config's `throttlingLimits`. The flip
1861    ///   is defensive there, not required.
1862    pub async fn repository_set_client_read_only(&self, read_only: bool) -> Result<(), KopiaError> {
1863        let mode = if read_only {
1864            "--read-only"
1865        } else {
1866            "--read-write"
1867        };
1868        self.run_ok(&[
1869            "repository".to_string(),
1870            "set-client".to_string(),
1871            mode.to_string(),
1872        ])
1873        .await
1874        .map(|_| ())
1875    }
1876
1877    /// Rewrite mutable repository parameters on the CONNECTED repository
1878    /// (`kopia repository set-parameters [flags]`), issue #258. No-op when nothing is set.
1879    ///
1880    /// Two properties the caller must respect:
1881    ///
1882    /// - **Never on a read-only connection.** kopia hard-errors (`unable to write blobcfg
1883    ///   blob: PutBlob() failed for "kopia.blobcfg": storage is read-only`), so a
1884    ///   `mode: ReadOnly` repository must not reach here.
1885    /// - **This invalidates every other client's cached format blob.** kopia says so itself
1886    ///   ("you must disconnect and re-connect all other Kopia clients") and drops the local
1887    ///   `kopia.repository`/`kopia.blobcfg` cache. Other clients re-read within
1888    ///   `formatBlobCacheDuration` (15m) on their own, but this is why the caller applies
1889    ///   only on observed drift rather than unconditionally.
1890    ///
1891    /// Needs no maintenance ownership/lease.
1892    pub async fn repository_set_parameters(
1893        &self,
1894        params: &SetParametersArgs,
1895    ) -> Result<(), KopiaError> {
1896        let flags = params.args();
1897        if flags.is_empty() {
1898            return Ok(());
1899        }
1900        let mut args = vec!["repository".into(), "set-parameters".into()];
1901        args.extend(flags);
1902        self.run_ok(&args).await.map(|_| ())
1903    }
1904
1905    /// Mirror the *connected* repository's blobs to a destination backend
1906    /// (`kopia repository sync-to <destination> [flags]`), ADR-0005 §13(d) / issue
1907    /// #216. The caller must already be connected to the **source** repository;
1908    /// this copies its blobs to `destination`. The destination's backend args are
1909    /// built by `ConnectSpec::backend_args` (the same builder connect/create use),
1910    /// so a new backend variant is wired through automatically. `opts` carries the
1911    /// tuning knobs (parallelism, `--delete`, the must-exist/times/update
1912    /// tri-states, throughput caps) — see [`SyncToOptions`]. Destination
1913    /// credentials are supplied via the environment, never on argv, exactly like
1914    /// connect/create. Success is exit code 0.
1915    pub async fn repository_sync_to(
1916        &self,
1917        destination: &ConnectSpec,
1918        opts: &SyncToOptions,
1919    ) -> Result<(), KopiaError> {
1920        self.repository_sync_to_with_env(destination, opts, &BTreeMap::new())
1921            .await
1922    }
1923
1924    /// [`Self::repository_sync_to`] with a per-invocation environment overlay
1925    /// applied to the `sync-to` subprocess only. The replication mover uses this to
1926    /// give the **destination** backend its own credentials: it maps each of the
1927    /// destination backend's env-delivered credential vars (`AWS_*`, `AZURE_*`, …)
1928    /// from the `KOPIUR_DEST_`-prefixed copy in its environment, and unsets any that
1929    /// the destination doesn't provide so a source credential cannot leak. The
1930    /// *source* repository is read from the persisted connection config (kopia bakes
1931    /// the source storage credentials in at `repository connect`), so overlaying the
1932    /// plain credential names here cannot disturb the source read. `Some(v)` sets a
1933    /// var, `None` removes it. Credentials travel via env, never argv.
1934    pub async fn repository_sync_to_with_env(
1935        &self,
1936        destination: &ConnectSpec,
1937        opts: &SyncToOptions,
1938        dest_env: &BTreeMap<String, Option<String>>,
1939    ) -> Result<(), KopiaError> {
1940        let args = sync_to_args(destination, opts);
1941        self.run_ok_with_env(&args, dest_env).await.map(|_| ())
1942    }
1943
1944    /// Create a snapshot of `source_path` with the given `tags`
1945    /// (`key:value`) and kopia's own defaults for `snapshot create`'s tuning
1946    /// knobs. Returns the parsed create result.
1947    ///
1948    /// `override_source`, when set, is passed to kopia as `--override-source`
1949    /// (format `username@hostname:path`). This is how Kopiur records snapshots
1950    /// under the operator-*resolved* identity (ADR §4.2 / anchoring principle 9)
1951    /// rather than the mover pod's ambient `user@host`. Without it kopia would
1952    /// attribute the snapshot to the pod, breaking the identity model that the
1953    /// whole catalog/retention/restore machinery keys on.
1954    pub async fn snapshot_create(
1955        &self,
1956        source_path: &str,
1957        tags: &BTreeMap<String, String>,
1958        override_source: Option<&str>,
1959    ) -> Result<SnapshotCreateResult, KopiaError> {
1960        self.snapshot_create_with(
1961            source_path,
1962            tags,
1963            override_source,
1964            &SnapshotCreateOptions::default(),
1965        )
1966        .await
1967    }
1968
1969    /// Create a snapshot honoring the operator's [`SnapshotCreateOptions`]
1970    /// (`failFast`, `uploadLimitMb`, `description` — M4 flag sweep, issue #216).
1971    /// Same identity/tags contract as [`Self::snapshot_create`], which now
1972    /// delegates here with an all-default `opts` (byte-for-byte the same argv
1973    /// as before this option struct existed).
1974    pub async fn snapshot_create_with(
1975        &self,
1976        source_path: &str,
1977        tags: &BTreeMap<String, String>,
1978        override_source: Option<&str>,
1979        opts: &SnapshotCreateOptions,
1980    ) -> Result<SnapshotCreateResult, KopiaError> {
1981        let args = snapshot_create_args(source_path, None, tags, override_source, opts);
1982        self.run_json(&args, "snapshot create result").await
1983    }
1984
1985    /// Create a snapshot whose CONTENT is fed to kopia on stdin
1986    /// (`kopia snapshot create <root> --stdin-file <name>`), storing it as one
1987    /// virtual regular file inside a virtual directory at `source_path`.
1988    ///
1989    /// # Why the caller decides when to commit
1990    ///
1991    /// `feed` BORROWS kopia's `ChildStdin` for the transfer and returns [`StdinOutcome`].
1992    /// kopia finalizes the snapshot when — and only when — stdin reaches EOF, so
1993    /// **holding stdin open is what keeps the snapshot uncommitted**:
1994    ///
1995    /// * [`StdinOutcome::Commit`] — this method closes the writer, kopia sees EOF,
1996    ///   writes the manifest, and exits 0.
1997    /// * [`StdinOutcome::Abort`] — kopia is KILLED with stdin still open, so it never
1998    ///   writes a manifest; the repository keeps only orphan content blobs, which
1999    ///   maintenance reclaims.
2000    ///
2001    /// This is verified behaviour, not an assumption: with the complete payload
2002    /// already written but stdin still open, killing kopia 0.23.1 leaves no snapshot
2003    /// for that source at all. It is what lets a caller guarantee "a producer that
2004    /// failed can never leave a retained, restorable snapshot" with no
2005    /// delete-after-the-fact and no race — the commit decision happens strictly after
2006    /// the producer's exit status is known.
2007    ///
2008    /// `feed` must never write the streamed bytes anywhere but the writer it is given:
2009    /// they are the user's data (a database dump), and this crate deliberately offers
2010    /// no path by which they could reach a log line or an error message.
2011    pub async fn snapshot_create_stdin_outcome_with<F>(
2012        &self,
2013        snapshot: StdinSnapshot<'_>,
2014        feed: F,
2015    ) -> Result<SnapshotCreateOutcome, KopiaError>
2016    where
2017        F: AsyncFnOnce(&mut StdinWriter<'_>) -> Result<StdinOutcome, KopiaError>,
2018    {
2019        let args = snapshot_create_args(
2020            snapshot.source_path,
2021            Some(snapshot.stdin_file),
2022            snapshot.tags,
2023            snapshot.override_source,
2024            snapshot.opts,
2025        );
2026        let out = self
2027            .run_with_stdin(&args, snapshot.finalize_timeout, feed)
2028            .await?;
2029        if out.code != Some(0) {
2030            return Err(KopiaError::NonZeroExit {
2031                args: args.join(" "),
2032                code: out.code,
2033                class: KopiaErrorClass::classify(&out.stderr),
2034                stderr_tail: tail_lines(&out.stderr),
2035            });
2036        }
2037        match extract_json(&out.stdout) {
2038            Some(json) => serde_json::from_str::<SnapshotCreateResult>(json)
2039                .map(|r| SnapshotCreateOutcome::Created(Box::new(r)))
2040                .map_err(|source| KopiaError::Json {
2041                    context: "snapshot create result".to_string(),
2042                    source,
2043                }),
2044            // A stdin snapshot has no previous tree to compare against, so kopia's
2045            // `ignoreIdenticalSnapshots` shortcut cannot really apply — but decode it
2046            // the same way rather than mis-reporting it as an empty-output failure.
2047            None if crate::error::snapshot_skipped_unchanged(&out.stderr) => {
2048                Ok(SnapshotCreateOutcome::Unchanged)
2049            }
2050            None => Err(KopiaError::EmptyOutput {
2051                context: "snapshot create result".to_string(),
2052                stderr_tail: tail_lines(&out.stderr),
2053            }),
2054        }
2055    }
2056
2057    /// Stream one object's bytes out of the repository (`kopia show <object-id>`),
2058    /// writing them into `sink`.
2059    ///
2060    /// `object_id` addresses an entry inside a snapshot as `<rootEntryObjectId>/<name>`.
2061    /// Note a snapshot MANIFEST id does NOT work in that sub-path form; the root
2062    /// entry's object id (`rootEntry.obj` from `snapshot list --json`) does.
2063    ///
2064    /// kopia streams the object, so this is constant-memory regardless of file size,
2065    /// and the bytes go straight into `sink` — never buffered here, never logged,
2066    /// never attached to an error.
2067    pub async fn show_to<W>(
2068        &self,
2069        object_id: &str,
2070        budget: Option<Duration>,
2071        mut sink: W,
2072    ) -> Result<(), KopiaError>
2073    where
2074        W: tokio::io::AsyncWrite + Unpin + Send,
2075    {
2076        // A thin wrapper over [`Self::run_raw_streaming`], which already does
2077        // exactly this for the browse data plane — byte-for-byte copy, bounded
2078        // stderr, the shared spawn retry. It previously had a near-duplicate of its
2079        // own (`run_streaming_stdout`) that skipped both the retry and any timeout,
2080        // so a `streamExec` restore could hang on a wedged kopia.
2081        //
2082        // `budget` is EXPLICIT rather than inherited from `default_timeout` because
2083        // sharing that one would give a stream restore a second, invisible bound: the
2084        // mover derives `default_timeout` from `spec.options.operationTimeout`, so a
2085        // user who set that for unrelated reasons would see a large stream restore
2086        // fail with a `Timeout` naming kopia's argv — pointing at a knob that has
2087        // nothing to do with the restore. The stream caller passes the CONSUMER's own
2088        // `workloadExec.timeout` instead, and translates the expiry into a message
2089        // naming that field. `None` falls back to `default_timeout`.
2090        let args: Vec<String> = vec!["show".into(), object_id.to_string()];
2091        self.run_raw_streaming_within(&args, budget, &mut sink)
2092            .await
2093            .map(|_| ())
2094    }
2095
2096    /// [`Self::snapshot_create_with`], but able to say "kopia deliberately
2097    /// wrote no manifest".
2098    ///
2099    /// `snapshot create` has exactly one legitimate silent success: with the
2100    /// retention knob `ignoreIdenticalSnapshots` on and the source
2101    /// byte-identical to the previous snapshot, kopia exits **0**, prints
2102    /// nothing on stdout, and says so only on stderr. Through `run_json` that
2103    /// was a hard `EmptyOutput` error and terminally failed the `Snapshot` CR
2104    /// (#351).
2105    ///
2106    /// Returning an enum rather than an `Option`/sentinel is deliberate: a
2107    /// deduped run owns **no kopia manifest**, and callers that quietly treated
2108    /// it as an ordinary success would go looking for one — which is how a CR
2109    /// ends up claiming its predecessor's manifest and deleting it on prune.
2110    /// The variant forces every caller to decide.
2111    ///
2112    /// Any *other* empty stdout still fails, loudly and now with kopia's own
2113    /// stderr attached.
2114    pub async fn snapshot_create_outcome_with(
2115        &self,
2116        source_path: &str,
2117        tags: &BTreeMap<String, String>,
2118        override_source: Option<&str>,
2119        opts: &SnapshotCreateOptions,
2120    ) -> Result<SnapshotCreateOutcome, KopiaError> {
2121        let args = snapshot_create_args(source_path, None, tags, override_source, opts);
2122        let out = self.run_ok_full(&args, &BTreeMap::new()).await?;
2123        match extract_json(&out.stdout) {
2124            Some(json) => serde_json::from_str::<SnapshotCreateResult>(json)
2125                .map(|r| SnapshotCreateOutcome::Created(Box::new(r)))
2126                .map_err(|source| KopiaError::Json {
2127                    context: "snapshot create result".to_string(),
2128                    source,
2129                }),
2130            None if crate::error::snapshot_skipped_unchanged(&out.stderr) => {
2131                Ok(SnapshotCreateOutcome::Unchanged)
2132            }
2133            None => Err(KopiaError::EmptyOutput {
2134                context: "snapshot create result".to_string(),
2135                stderr_tail: tail_lines(&out.stderr),
2136            }),
2137        }
2138    }
2139
2140    /// List COMPLETE snapshots, optionally filtered by source identity. With no
2141    /// filter this lists all snapshots in the repository.
2142    ///
2143    /// **Incomplete manifests are dropped**: kopia's JSON
2144    /// listing emits the checkpoints an interrupted `snapshot create` leaves
2145    /// behind even without `--incomplete`, and no caller of a "snapshot list"
2146    /// means a partial upload (issue #477). Use
2147    /// [`Self::snapshot_list_all_with_incomplete`] for manifest-id presence.
2148    pub async fn snapshot_list(
2149        &self,
2150        filter: Option<&SnapshotSource>,
2151    ) -> Result<Vec<SnapshotListEntry>, KopiaError> {
2152        let mut args = vec!["snapshot".into(), "list".into(), "--json".into()];
2153        if let Some(src) = filter {
2154            // kopia accepts the identity string as a positional source filter.
2155            args.push(src.identity());
2156        }
2157        Ok(complete_only(self.run_json(&args, "snapshot list").await?))
2158    }
2159
2160    /// List EVERY snapshot in the repository regardless of owning identity
2161    /// (`kopia snapshot list --json --all`).
2162    ///
2163    /// **What `--all` actually does** (verified against kopia 0.23.1, whose help
2164    /// text — "Show all snapshots (not just current username/host)" — reads more
2165    /// broadly than the behavior): the flag only takes effect when a `<source>`
2166    /// positional is supplied. A *source-less* `snapshot list --json` already
2167    /// returns every identity in the repository, foreign ones included; it is
2168    /// NOT scoped to the connected `user@host`. So [`Self::snapshot_list`] with
2169    /// `filter: None` is not, today, the identity-scoped call the flag name
2170    /// suggests.
2171    ///
2172    /// Use this one anyway wherever the count or the set is load-bearing —
2173    /// replication enumeration, post-migrate verification, the `spec.seed`
2174    /// empty-repository backstop. Those are decisions that strand or accept a
2175    /// repository, and none of them should rest on a kopia default that could
2176    /// change without the flag name changing with it. The behavior above is
2177    /// pinned by the `sync_to_seeds_*` integration test, which asserts a
2178    /// foreign-identity-only repository lists its snapshots BOTH ways.
2179    ///
2180    /// Complete snapshots only, like [`Self::snapshot_list`].
2181    pub async fn snapshot_list_all(&self) -> Result<Vec<SnapshotListEntry>, KopiaError> {
2182        Ok(complete_only(
2183            self.snapshot_list_all_with_incomplete().await?,
2184        ))
2185    }
2186
2187    /// [`Self::snapshot_list_all`] WITHOUT dropping incomplete manifests: every
2188    /// manifest id in the repository, checkpoints included (tell them apart with
2189    /// [`SnapshotListEntry::incomplete_reason`] / [`crate::partition_incomplete`]).
2190    /// For manifest-id PRESENCE checks — e.g. "is this id still there to
2191    /// delete?" — and for callers that report what they skipped. Never pick a
2192    /// snapshot to restore, verify or replicate from this listing.
2193    pub async fn snapshot_list_all_with_incomplete(
2194        &self,
2195    ) -> Result<Vec<SnapshotListEntry>, KopiaError> {
2196        self.run_json(&snapshot_list_all_args(), "snapshot list --all")
2197            .await
2198    }
2199
2200    /// Copy snapshot manifests (and their content) from ANOTHER repository
2201    /// into the CONNECTED one (`kopia snapshot migrate --source-config
2202    /// <path>`). The caller must already be connected to the **destination**;
2203    /// the source repository is opened from the config file named in
2204    /// [`SnapshotMigrateOptions::source_config_path`], authenticating with
2205    /// that config's PERSISTED password (see
2206    /// [`ConnectOptions::persist_credentials`]) — this client's
2207    /// `KOPIA_PASSWORD` belongs to the destination. `snapshot migrate` has no
2208    /// `--json`; success is exit code 0, like
2209    /// [`Self::repository_sync_to`].
2210    ///
2211    /// **CAVEAT — kopia exits 0 even when a per-source migration failed**: the
2212    /// per-source migration goroutines only LOG their errors (verified against
2213    /// kopia 0.23.1's `cli/command_snapshot_migrate.go`), so a zero exit does
2214    /// NOT mean every selected snapshot arrived. Callers MUST post-verify by
2215    /// listing the destination ([`Self::snapshot_list_all`]) and checking that
2216    /// every expected `(identity, startTime)` pair is present.
2217    pub async fn snapshot_migrate(&self, opts: &SnapshotMigrateOptions) -> Result<(), KopiaError> {
2218        self.run_ok(&snapshot_migrate_args(opts)).await.map(|_| ())
2219    }
2220
2221    /// Delete many snapshot manifests in ONE kopia invocation
2222    /// (`kopia snapshot delete <id>... --delete`): one repository open and one
2223    /// index load for the whole set, instead of one per id (issue #477).
2224    ///
2225    /// **All-or-nothing** (verified against a real kopia 0.23.1 repository):
2226    /// kopia's CLI loop stops at the first failing id, and its write session
2227    /// flushes only when every id succeeded. On any error NOTHING was deleted,
2228    /// even the ids kopia already logged as "Deleting snapshot …".
2229    ///
2230    /// So unlike [`Self::snapshot_delete`], a `no snapshots matched` failure is
2231    /// NOT remapped to success. Here it means one id was absent and the whole
2232    /// call committed nothing. Pass only ids a fresh listing shows present, and
2233    /// keep the call small enough for one argv (callers chunk).
2234    pub async fn snapshot_delete_many(&self, ids: &[String]) -> Result<(), KopiaError> {
2235        let mut args = vec!["snapshot".to_string(), "delete".to_string()];
2236        args.extend(ids.iter().cloned());
2237        args.push("--delete".into());
2238        self.run_ok(&args).await.map(|_| ())
2239    }
2240
2241    /// Delete a single snapshot by manifest id. kopia's `snapshot delete`
2242    /// requires `--delete` to actually remove (otherwise it dry-runs) and does
2243    /// not support `--json`; success is signaled by exit code 0.
2244    ///
2245    /// IDEMPOTENT: an already-absent snapshot (`no snapshots matched <id>` on
2246    /// stderr) is success — that IS the goal state. kopia dedups
2247    /// identical-content snapshot manifests, so several `Snapshot` CRs can
2248    /// legitimately pin the SAME kopia id; when GFS retention prunes more than
2249    /// one of them, the first finalizer's delete removes the manifest and the
2250    /// rest would otherwise fail terminally, wedging their CRs in `Deleting`
2251    /// forever (caught by the retention e2e under suite load).
2252    pub async fn snapshot_delete(&self, id: &str) -> Result<(), KopiaError> {
2253        let args = vec![
2254            "snapshot".into(),
2255            "delete".into(),
2256            id.to_string(),
2257            "--delete".into(),
2258        ];
2259        match self.run_ok(&args).await {
2260            Ok(_) => Ok(()),
2261            Err(KopiaError::NonZeroExit { stderr_tail, .. })
2262                if stderr_tail.contains("no snapshots matched") =>
2263            {
2264                tracing::debug!(%id, "snapshot already absent; delete is idempotent");
2265                Ok(())
2266            }
2267            Err(e) => Err(e),
2268        }
2269    }
2270
2271    /// Restore a snapshot's contents to a target directory with kopia's default
2272    /// options. kopia's `snapshot restore` does not emit JSON; success is exit
2273    /// code 0.
2274    pub async fn snapshot_restore(&self, id: &str, target_dir: &str) -> Result<(), KopiaError> {
2275        self.snapshot_restore_with(id, target_dir, &RestoreOptions::default())
2276            .await
2277    }
2278
2279    /// Restore a snapshot honoring the operator's [`RestoreOptions`]
2280    /// (`enableFileDeletion`, `ignorePermissionErrors`, `writeFilesAtomically`,
2281    /// …). Success is exit code 0.
2282    pub async fn snapshot_restore_with(
2283        &self,
2284        id: &str,
2285        target_dir: &str,
2286        opts: &RestoreOptions,
2287    ) -> Result<(), KopiaError> {
2288        let args = restore_args(id, target_dir, opts);
2289        self.run_ok(&args).await.map(|_| ())
2290    }
2291
2292    /// Verify repository/snapshot integrity (`kopia snapshot verify`). Success is
2293    /// exit code 0; a verification failure surfaces as a non-zero exit.
2294    pub async fn snapshot_verify(&self, opts: &VerifyOptions) -> Result<(), KopiaError> {
2295        let args = verify_args(opts);
2296        self.run_ok(&args).await.map(|_| ())
2297    }
2298
2299    /// Estimate the size/scope of snapshotting `source_path`
2300    /// (`kopia snapshot estimate`). Best-effort; success is exit code 0.
2301    pub async fn snapshot_estimate(&self, source_path: &str) -> Result<(), KopiaError> {
2302        let args = vec![
2303            "snapshot".into(),
2304            "estimate".into(),
2305            source_path.to_string(),
2306        ];
2307        self.run_ok(&args).await.map(|_| ())
2308    }
2309
2310    /// Add a pin to a snapshot so maintenance/expiration never deletes it
2311    /// (`kopia snapshot pin <id> --add <pin>`). Used to protect snapshots whose
2312    /// `Snapshot` carries `deletionPolicy: Retain`.
2313    pub async fn snapshot_pin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
2314        let args = vec![
2315            "snapshot".into(),
2316            "pin".into(),
2317            id.to_string(),
2318            "--add".into(),
2319            pin.to_string(),
2320        ];
2321        self.run_ok(&args).await.map(|_| ())
2322    }
2323
2324    /// Remove a pin from a snapshot (`kopia snapshot pin <id> --remove <pin>`).
2325    pub async fn snapshot_unpin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
2326        let args = vec![
2327            "snapshot".into(),
2328            "pin".into(),
2329            id.to_string(),
2330            "--remove".into(),
2331            pin.to_string(),
2332        ];
2333        self.run_ok(&args).await.map(|_| ())
2334    }
2335
2336    /// Expire snapshots per the repository's policy
2337    /// (`kopia snapshot expire --all`). When `delete` is false this is a dry-run
2338    /// (kopia requires `--delete` to actually remove). Success is exit code 0.
2339    pub async fn snapshot_expire(&self, delete: bool) -> Result<(), KopiaError> {
2340        let mut args = vec!["snapshot".into(), "expire".into(), "--all".into()];
2341        if delete {
2342            args.push("--delete".into());
2343        }
2344        self.run_ok(&args).await.map(|_| ())
2345    }
2346
2347    /// Validate that the connected storage provider behaves correctly
2348    /// (`kopia repository validate-provider`). A good Repository-readiness
2349    /// preflight for object-store backends. Success is exit code 0.
2350    pub async fn repository_validate_provider(&self) -> Result<(), KopiaError> {
2351        let args = vec!["repository".into(), "validate-provider".into()];
2352        self.run_ok(&args).await.map(|_| ())
2353    }
2354
2355    /// Apply a policy to `target` (an identity string, a path, or `--global`)
2356    /// via `kopia policy set`. The operator calls this before the first snapshot
2357    /// so `SnapshotPolicy.spec.policy` (compression/splitter/ignore) is honored.
2358    pub async fn policy_set(&self, target: &str, policy: &PolicyArgs) -> Result<(), KopiaError> {
2359        let args = policy_set_args(target, policy);
2360        self.run_ok(&args).await.map(|_| ())
2361    }
2362
2363    /// Show the effective policy for `target` (`kopia policy show <target>
2364    /// --json`), parsed as a generic JSON value.
2365    pub async fn policy_show(&self, target: &str) -> Result<serde_json::Value, KopiaError> {
2366        let args = vec![
2367            "policy".into(),
2368            "show".into(),
2369            target.to_string(),
2370            "--json".into(),
2371        ];
2372        self.run_json(&args, "policy show").await
2373    }
2374
2375    /// Get repository status (`kopia repository status --json`).
2376    ///
2377    /// This spawns `kopia`, so the example is `no_run` (it would need a real
2378    /// binary + connected repository):
2379    ///
2380    /// ```no_run
2381    /// # async fn run() -> Result<(), kopiur_kopia::KopiaError> {
2382    /// use kopiur_kopia::KopiaClient;
2383    ///
2384    /// let client = KopiaClient::builder()
2385    ///     .env("KOPIA_PASSWORD", "s3cr3t")
2386    ///     .build();
2387    /// let status = client.repository_status().await?;
2388    /// println!("repository unique id: {}", status.unique_id_hex);
2389    /// # Ok(())
2390    /// # }
2391    /// ```
2392    pub async fn repository_status(&self) -> Result<RepositoryStatus, KopiaError> {
2393        let args = vec!["repository".into(), "status".into(), "--json".into()];
2394        self.run_json(&args, "repository status").await
2395    }
2396
2397    /// Get maintenance info (`kopia maintenance info --json`).
2398    pub async fn maintenance_info(&self) -> Result<MaintenanceInfo, KopiaError> {
2399        let args = vec!["maintenance".into(), "info".into(), "--json".into()];
2400        self.run_json(&args, "maintenance info").await
2401    }
2402
2403    /// Count the repository's content-index blobs (`kopia index list --json`,
2404    /// array length). kopia's index is compacted by periodic maintenance; when
2405    /// maintenance stops (e.g. a stale lease owner), this grows unbounded and
2406    /// kopia eventually warns "Found too many index blobs (N), ensure periodic
2407    /// repository maintenance". The operator surfaces a Kubernetes Warning when
2408    /// this crosses a configurable threshold. Cheap — lists index-blob metadata
2409    /// only, no content read. Verified against kopia 0.23 (`index list
2410    /// --[no-]json`).
2411    pub async fn index_blob_count(&self) -> Result<i64, KopiaError> {
2412        let args = vec!["index".into(), "list".into(), "--json".into()];
2413        let entries: Vec<IndexBlobEntry> = self.run_json(&args, "index list").await?;
2414        Ok(entries.len() as i64)
2415    }
2416
2417    /// Claim the repository's maintenance ownership for the *currently connected*
2418    /// identity (`kopia maintenance set --owner me`). kopia ties "who may run
2419    /// maintenance" to the connected user@hostname and rejects a `maintenance run`
2420    /// from anyone but the designated owner ("maintenance must be run by designated
2421    /// user: …"). A repo bootstrapped by the controller in-process is owned by the
2422    /// controller's identity, so a mover Job (a different pod) MUST claim ownership
2423    /// before it can run maintenance. Idempotent; no JSON, success is exit 0.
2424    pub async fn maintenance_set_owner_me(&self) -> Result<(), KopiaError> {
2425        let args = vec![
2426            "maintenance".into(),
2427            "set".into(),
2428            "--owner".into(),
2429            "me".into(),
2430        ];
2431        self.run_ok(&args).await.map(|_| ())
2432    }
2433
2434    /// Set the repository's maintenance owner to an EXPLICIT `user@hostname`
2435    /// (`kopia maintenance set --owner <owner>`). Used by the bootstrap mover
2436    /// right after `repository create` to stamp the stable, lease-derived
2437    /// owner (`kopiur_api::maintenance::kopia_owner_for_lease`) instead of the
2438    /// creating pod's ephemeral identity — without this, every later
2439    /// maintenance mover sees a foreign owner and `takeoverPolicy: Never`
2440    /// yields forever. Verified against kopia 0.23 `maintenance set --help`
2441    /// (`--owner=OWNER  Set maintenance owner user@hostname`).
2442    pub async fn maintenance_set_owner(&self, owner: &str) -> Result<(), KopiaError> {
2443        let args = vec![
2444            "maintenance".into(),
2445            "set".into(),
2446            "--owner".into(),
2447            owner.into(),
2448        ];
2449        self.run_ok(&args).await.map(|_| ())
2450    }
2451
2452    /// Switch the CONNECTED client identity (`kopia repository set-client
2453    /// --username … --hostname …`). The maintenance mover assumes the stable
2454    /// lease-derived identity this way (kopia 0.23 has no identity override on
2455    /// `repository connect`; the OS user@pod-hostname is ephemeral), so
2456    /// `maintenance set --owner me` records a stable string and the
2457    /// designated-user check passes on every later run. Verified against
2458    /// kopia 0.23 `repository set-client --help`.
2459    pub async fn repository_set_client_identity(
2460        &self,
2461        username: &str,
2462        hostname: &str,
2463    ) -> Result<(), KopiaError> {
2464        let args = vec![
2465            "repository".into(),
2466            "set-client".into(),
2467            format!("--username={username}"),
2468            format!("--hostname={hostname}"),
2469        ];
2470        self.run_ok(&args).await.map(|_| ())
2471    }
2472
2473    /// Run a maintenance pass. kopia's `maintenance run` does not emit JSON;
2474    /// success is exit code 0. The caller must already be the designated
2475    /// maintenance owner (see [`maintenance_set_owner_me`](Self::maintenance_set_owner_me)).
2476    pub async fn maintenance_run(&self, mode: MaintenanceMode) -> Result<(), KopiaError> {
2477        let mut args = vec!["maintenance".into(), "run".into()];
2478        match mode {
2479            MaintenanceMode::Quick => args.push("--no-full".into()),
2480            MaintenanceMode::Full => args.push("--full".into()),
2481        }
2482        self.run_ok(&args).await.map(|_| ())
2483    }
2484
2485    /// Run kopia with `args` and stream stdout **byte-for-byte** into `sink`
2486    /// (no line splitting, no UTF-8 assumption), returning the byte count on a
2487    /// zero exit. This is the file-content path for `kopia show <file-oid>`
2488    /// (the browse data-plane's `cat`/`download`), where stdout is the raw
2489    /// object bytes — buffering it whole or splitting it into lines would
2490    /// corrupt/clamp arbitrarily large binary files.
2491    ///
2492    /// stderr is accumulated like every other invocation; a non-zero exit
2493    /// yields [`KopiaError::NonZeroExit`] with the stderr tail. NOTE: bytes
2494    /// already streamed before a late failure have reached the sink — callers
2495    /// writing to a file should verify the count and discard partial output on
2496    /// error (kopia's `show` either streams the object or fails up front, so in
2497    /// practice a failure produces no payload). Honors `default_timeout`.
2498    pub async fn run_raw_streaming(
2499        &self,
2500        args: &[String],
2501        sink: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
2502    ) -> Result<u64, KopiaError> {
2503        self.run_raw_streaming_within(args, None, sink).await
2504    }
2505
2506    /// [`Self::run_raw_streaming`] with an EXPLICIT budget that overrides this
2507    /// client's `default_timeout`.
2508    ///
2509    /// Exists because two callers want different bounds over the same mechanics:
2510    /// the browse data plane is happy with the client-wide
2511    /// `spec.options.operationTimeout`, while a `streamExec` restore must be
2512    /// bounded by the consumer command's own `workloadExec.timeout` so an expiry
2513    /// names the field the user actually set. `None` keeps `default_timeout`.
2514    pub async fn run_raw_streaming_within(
2515        &self,
2516        args: &[String],
2517        budget: Option<Duration>,
2518        sink: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
2519    ) -> Result<u64, KopiaError> {
2520        let display_args = args.join(" ");
2521        let mut cmd = self.base_command(args);
2522        cmd.stdin(Stdio::null());
2523        cmd.stdout(Stdio::piped());
2524        cmd.stderr(Stdio::piped());
2525
2526        let mut child = self.spawn_with_retry(&mut cmd).await?;
2527        let mut stdout_pipe = child.stdout.take().expect("stdout piped");
2528        let mut stderr_pipe = child.stderr.take().expect("stderr piped");
2529
2530        let copy_out = tokio::io::copy(&mut stdout_pipe, sink);
2531        let read_err = async {
2532            let mut buf = String::new();
2533            stderr_pipe.read_to_string(&mut buf).await.map(|_| buf)
2534        };
2535        // The three halves are NOT interchangeable, so they do not share one error.
2536        // `copied` is a write to the CONSUMER's sink; `err`/`status` are kopia's own
2537        // stderr and exit. Collapsing them into one `io::Error` is what made a
2538        // `streamExec` restore whose consumer pipe broke report "failed to spawn
2539        // kopia binary `kopia`: broken pipe" — blaming a binary that had spawned
2540        // and was streaming. `StreamHalves` keeps the attribution.
2541        let wait_with_io = async {
2542            let (copied, err, status) = tokio::join!(copy_out, read_err, child.wait());
2543            match copied {
2544                Ok(bytes) => Ok::<_, StreamHalves>((bytes, err?, status?)),
2545                Err(source) => Err(StreamHalves::Sink(source)),
2546            }
2547        };
2548
2549        let attribute = |h: StreamHalves| h.into_kopia_error(&self.binary, &display_args);
2550        let (bytes, stderr, status) = match budget.or(self.default_timeout) {
2551            Some(t) => match tokio::time::timeout(t, wait_with_io).await {
2552                Ok(res) => res.map_err(attribute)?,
2553                Err(_) => {
2554                    kill_and_reap(&mut child).await;
2555                    return Err(KopiaError::Timeout {
2556                        args: display_args,
2557                        seconds: t.as_secs(),
2558                    });
2559                }
2560            },
2561            None => wait_with_io.await.map_err(attribute)?,
2562        };
2563
2564        if status.code() == Some(0) {
2565            Ok(bytes)
2566        } else {
2567            Err(KopiaError::NonZeroExit {
2568                args: display_args,
2569                code: status.code(),
2570                class: KopiaErrorClass::classify(&stderr),
2571                stderr_tail: tail_lines(&stderr),
2572            })
2573        }
2574    }
2575
2576    /// Run `kopia server start`, **replacing this process** with kopia via `exec`.
2577    ///
2578    /// On success this never returns (kopia takes over the PID and runs until it is
2579    /// signalled). It returns a [`KopiaError`] only if `exec` itself fails (e.g. the
2580    /// binary is missing). The repository must already be connected (call
2581    /// [`KopiaClient::repository_connect`] first) — the server reads the connected
2582    /// repo from the kopia config file.
2583    ///
2584    /// `password` is the UI password for [`ServerAuthMode::Password`]; it is appended
2585    /// to argv **here**, inside the server pod, so it never reaches the controller,
2586    /// a ConfigMap, or the pure [`server_start_args`] builder. For
2587    /// [`ServerAuthMode::None`] it is ignored.
2588    #[cfg(unix)]
2589    pub fn server_start(&self, spec: &ServerStartSpec, password: Option<&str>) -> KopiaError {
2590        use std::os::unix::process::CommandExt;
2591
2592        let mut args = server_start_args(spec);
2593        if let ServerAuthMode::Password { .. } = &spec.auth
2594            && let Some(pw) = password
2595        {
2596            args.push("--server-password".into());
2597            args.push(pw.to_string());
2598        }
2599
2600        let mut cmd = std::process::Command::new(&self.binary);
2601        for (k, v) in &self.common_env {
2602            cmd.env(k, v);
2603        }
2604        for k in &self.common_env_remove {
2605            cmd.env_remove(k);
2606        }
2607        cmd.args(&args);
2608        cmd.args(&self.common_args);
2609
2610        // exec(2) replaces the current image. It returns ONLY on failure.
2611        let source = cmd.exec();
2612        KopiaError::Spawn {
2613            binary: self.binary.display().to_string(),
2614            source,
2615        }
2616    }
2617}
2618
2619/// Push a kingpin `--[no-]flag` boolean tri-state (`Some(true)` → `--flag`,
2620/// `Some(false)` → `--no-flag`). This is `kopia snapshot restore`'s flag
2621/// grammar (`--[no-]overwrite-files`, …) — NOT `policy set`'s (see
2622/// [`push_valued_tristate`]); the two commands genuinely differ.
2623fn push_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
2624    match value {
2625        Some(true) => args.push(format!("--{flag}")),
2626        Some(false) => args.push(format!("--no-{flag}")),
2627        None => {}
2628    }
2629}
2630
2631/// Push a kopia `policy set` boolean knob. These are VALUED flags
2632/// (`--flag=true|false`, "inherit" being the unset state) — NOT kingpin
2633/// `--flag/--no-flag` booleans: a bare `--ignore-file-errors` fails with
2634/// "expected argument for flag" (caught by the `policy_knobs` e2e; the old
2635/// `--no-` form never reached kopia). Verified against
2636/// `kopia policy set --help` (0.23).
2637fn push_valued_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
2638    match value {
2639        Some(true) => args.push(format!("--{flag}=true")),
2640        Some(false) => args.push(format!("--{flag}=false")),
2641        None => {}
2642    }
2643}
2644
2645/// Split [`PolicyArgs`] into the path-scoped part and an optional
2646/// identity-scoped part. kopia rejects `--max-parallel-snapshots` on a
2647/// path-scoped policy ("max parallel snapshots cannot be specified for paths,
2648/// only global, username@hostname or @hostname" — the `policy_knobs` e2e
2649/// regression), so that one knob must be applied in a second `policy set`
2650/// against the bare `username@hostname` identity. Pure.
2651pub fn split_policy_scopes(mut policy: PolicyArgs) -> (PolicyArgs, Option<PolicyArgs>) {
2652    let identity = policy.max_parallel_snapshots.take().map(|n| PolicyArgs {
2653        max_parallel_snapshots: Some(n),
2654        ..Default::default()
2655    });
2656    (policy, identity)
2657}
2658
2659/// Build the args for `kopia snapshot restore <id> <target>` plus options. Pure
2660/// so it is unit-testable without spawning kopia. Every `--[no-]flag` form here
2661/// was smoke-tested against the pinned kopia 0.23.1 (`kopia snapshot restore
2662/// --help`); the real-kopia integration test in
2663/// `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard that
2664/// kopia actually accepts them, not just that the argv shape looks right.
2665fn restore_args(id: &str, target_dir: &str, opts: &RestoreOptions) -> Vec<String> {
2666    let mut args = vec![
2667        "snapshot".into(),
2668        "restore".into(),
2669        id.to_string(),
2670        target_dir.to_string(),
2671    ];
2672    push_tristate(
2673        &mut args,
2674        "ignore-permission-errors",
2675        opts.ignore_permission_errors,
2676    );
2677    push_tristate(
2678        &mut args,
2679        "write-files-atomically",
2680        opts.write_files_atomically,
2681    );
2682    push_tristate(&mut args, "overwrite-files", opts.overwrite_files);
2683    push_tristate(
2684        &mut args,
2685        "overwrite-directories",
2686        opts.overwrite_directories,
2687    );
2688    push_tristate(&mut args, "overwrite-symlinks", opts.overwrite_symlinks);
2689    push_tristate(&mut args, "write-sparse-files", opts.write_sparse_files);
2690    push_tristate(&mut args, "skip-owners", opts.skip_owners);
2691    push_tristate(&mut args, "skip-permissions", opts.skip_permissions);
2692    push_tristate(&mut args, "skip-times", opts.skip_times);
2693    push_tristate(&mut args, "ignore-errors", opts.ignore_errors);
2694    push_tristate(&mut args, "skip-existing", opts.skip_existing);
2695    push_tristate(&mut args, "delete-extra", opts.delete_extra);
2696    if let Some(p) = opts.parallel {
2697        args.push("--parallel".into());
2698        args.push(p.to_string());
2699    }
2700    args
2701}
2702
2703/// Build the args for `kopia snapshot create <source> --json [flags]` plus
2704/// options. Pure so it is unit-testable without spawning kopia. `--fail-fast`
2705/// is a kingpin `--[no-]flag` tri-state (smoke-tested against the pinned
2706/// kopia 0.23.1: `snapshot create --fail-fast --upload-limit-mb 100
2707/// --description "smoke test"` is accepted; the real-kopia integration test
2708/// in `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard).
2709/// All-default `opts` reproduces the pre-M4 argv byte-for-byte (tested).
2710fn snapshot_create_args(
2711    source_path: &str,
2712    stdin_file: Option<&str>,
2713    tags: &BTreeMap<String, String>,
2714    override_source: Option<&str>,
2715    opts: &SnapshotCreateOptions,
2716) -> Vec<String> {
2717    let mut args = vec![
2718        "snapshot".into(),
2719        "create".into(),
2720        source_path.to_string(),
2721        "--json".into(),
2722    ];
2723    // `--stdin-file <name>` switches kopia from walking `source_path` on disk to
2724    // reading STDIN and storing it as ONE regular file of that name inside a virtual
2725    // directory rooted at `source_path`. The value is stored VERBATIM as the entry
2726    // name — kopia does not sanitize it — which is why callers must have validated it
2727    // as a single safe path segment (`validate_stream_file_name`).
2728    if let Some(name) = stdin_file {
2729        args.push("--stdin-file".into());
2730        args.push(name.to_string());
2731    }
2732    if let Some(src) = override_source {
2733        args.push("--override-source".into());
2734        args.push(src.to_string());
2735    }
2736    for (k, v) in tags {
2737        args.push("--tags".into());
2738        args.push(format!("{k}:{v}"));
2739    }
2740    push_tristate(&mut args, "fail-fast", opts.fail_fast);
2741    if let Some(mb) = opts.upload_limit_mb {
2742        args.push("--upload-limit-mb".into());
2743        args.push(mb.to_string());
2744    }
2745    if let Some(desc) = &opts.description {
2746        args.push("--description".into());
2747        args.push(desc.clone());
2748    }
2749    args
2750}
2751
2752/// Build the args for `kopia snapshot verify` plus options. Pure.
2753fn verify_args(opts: &VerifyOptions) -> Vec<String> {
2754    let mut args = vec!["snapshot".into(), "verify".into()];
2755    for src in &opts.sources {
2756        args.push("--sources".into());
2757        args.push(src.clone());
2758    }
2759    if let Some(pct) = opts.verify_files_percent {
2760        args.push("--verify-files-percent".into());
2761        args.push(pct.to_string());
2762    }
2763    if let Some(m) = opts.max_errors {
2764        args.push("--max-errors".into());
2765        args.push(m.to_string());
2766    }
2767    if let Some(p) = opts.parallel {
2768        args.push("--parallel".into());
2769        args.push(p.to_string());
2770    }
2771    if let Some(p) = opts.file_parallelism {
2772        args.push("--file-parallelism".into());
2773        args.push(p.to_string());
2774    }
2775    if let Some(q) = opts.file_queue_length {
2776        args.push("--file-queue-length".into());
2777        args.push(q.to_string());
2778    }
2779    args
2780}
2781
2782/// Build the args for `kopia repository connect <backend> [flags]`. Pure so the
2783/// option → argv mapping is unit-testable without spawning kopia.
2784/// `--readonly` (kopia's persistent read-only client-config bit) is appended
2785/// only for read-only connects (browse sessions; replication source connects),
2786/// then `--persist-credentials` when the password must be written beside the
2787/// config file. An all-default [`ConnectOptions`] appends nothing — byte-for-
2788/// byte the pre-`ConnectOptions` read-write argv.
2789fn connect_args(spec: &ConnectSpec, cache: CacheTuning, opts: ConnectOptions) -> Vec<String> {
2790    let mut args = vec!["repository".into(), "connect".into()];
2791    args.extend(spec.backend_args());
2792    args.extend(cache.args());
2793    if opts.readonly {
2794        args.push("--readonly".into());
2795    }
2796    if opts.persist_credentials {
2797        args.push("--persist-credentials".into());
2798    }
2799    args
2800}
2801
2802/// Build the args for `kopia snapshot list --json --all`. Pure so the
2803/// every-identity list argv is unit-testable without spawning kopia.
2804/// Drop incomplete manifests from a raw listing, logging what was dropped.
2805fn complete_only(entries: Vec<SnapshotListEntry>) -> Vec<SnapshotListEntry> {
2806    let (complete, incomplete) = crate::partition_incomplete(entries);
2807    for e in &incomplete {
2808        tracing::debug!(
2809            id = %e.id,
2810            source = %e.source.identity(),
2811            reason = e.incomplete_reason().unwrap_or_default(),
2812            "skipping incomplete snapshot manifest"
2813        );
2814    }
2815    complete
2816}
2817
2818fn snapshot_list_all_args() -> Vec<String> {
2819    vec![
2820        "snapshot".into(),
2821        "list".into(),
2822        "--json".into(),
2823        "--all".into(),
2824    ]
2825}
2826
2827/// Build the args for `kopia snapshot migrate` plus options. Pure so it is
2828/// unit-testable without spawning kopia. Exact shape: `["snapshot", "migrate",
2829/// "--source-config", <path>]`, then `--all` OR one `--sources <spec>` per
2830/// list entry, then `--latest-only` when set, `--parallel <n>` when set, then
2831/// the policy mode. [`MigratePolicies::None`] MUST render an explicit
2832/// `--no-policies` — kopia's own default for `--policies` is TRUE, so omission
2833/// would silently import the source's kopia policies.
2834fn snapshot_migrate_args(opts: &SnapshotMigrateOptions) -> Vec<String> {
2835    let mut args = vec![
2836        "snapshot".into(),
2837        "migrate".into(),
2838        "--source-config".into(),
2839        opts.source_config_path.clone(),
2840    ];
2841    match &opts.sources {
2842        MigrateSources::All => args.push("--all".into()),
2843        MigrateSources::List(specs) => {
2844            for spec in specs {
2845                args.push("--sources".into());
2846                args.push(spec.clone());
2847            }
2848        }
2849    }
2850    if opts.latest_only {
2851        args.push("--latest-only".into());
2852    }
2853    if let Some(p) = opts.parallel {
2854        args.push("--parallel".into());
2855        args.push(p.to_string());
2856    }
2857    match opts.policies {
2858        MigratePolicies::None => args.push("--no-policies".into()),
2859        MigratePolicies::Copy => args.push("--policies".into()),
2860        MigratePolicies::CopyOverwrite => {
2861            args.push("--policies".into());
2862            args.push("--overwrite-policies".into());
2863        }
2864    }
2865    args
2866}
2867
2868/// Build the args for `kopia repository sync-to <destination> [flags]`. Pure so it
2869/// is unit-testable without spawning kopia (ADR-0005 §13(d) / issue #216). The
2870/// destination's backend selection reuses `ConnectSpec::backend_args`, so every
2871/// backend is wired through. `--must-exist`/`--times`/`--update` are kopia
2872/// (kingpin) BOOLEAN flags: `--must-exist=false` is a parse error (`unexpected
2873/// false`) — but the `--no-must-exist`/`--no-times`/`--no-update` negated forms
2874/// ARE accepted (smoke-tested against kopia 0.23.1), so [`push_tristate`] is used
2875/// for all three exactly like `snapshot restore`'s tri-states. `None` on any
2876/// field omits its flag entirely, leaving kopia's own default in effect.
2877fn sync_to_args(destination: &ConnectSpec, opts: &SyncToOptions) -> Vec<String> {
2878    let mut args = vec!["repository".into(), "sync-to".into()];
2879    args.extend(destination.backend_args());
2880    if let Some(p) = opts.parallel {
2881        args.push("--parallel".into());
2882        args.push(p.to_string());
2883    }
2884    if opts.delete_extra {
2885        args.push("--delete".into());
2886    }
2887    push_tristate(&mut args, "must-exist", opts.must_exist);
2888    push_tristate(&mut args, "times", opts.times);
2889    push_tristate(&mut args, "update", opts.update);
2890    if let Some(s) = opts.max_download_speed_bytes_per_second {
2891        args.push("--max-download-speed".into());
2892        args.push(s.to_string());
2893    }
2894    if let Some(s) = opts.max_upload_speed_bytes_per_second {
2895        args.push("--max-upload-speed".into());
2896        args.push(s.to_string());
2897    }
2898    args
2899}
2900
2901/// Build the args for `kopia policy set <target>` plus flags. Pure.
2902fn policy_set_args(target: &str, policy: &PolicyArgs) -> Vec<String> {
2903    let mut args = vec!["policy".into(), "set".into(), target.to_string()];
2904    if let Some(c) = &policy.compression {
2905        args.push("--compression".into());
2906        args.push(c.clone());
2907    }
2908    if let Some(s) = &policy.splitter {
2909        args.push("--splitter".into());
2910        args.push(s.clone());
2911    }
2912    for pat in &policy.ignore {
2913        args.push("--add-ignore".into());
2914        args.push(pat.clone());
2915    }
2916    for pat in &policy.never_compress {
2917        args.push("--add-never-compress".into());
2918        args.push(pat.clone());
2919    }
2920    push_valued_tristate(&mut args, "ignore-cache-dirs", policy.ignore_cache_dirs);
2921    push_valued_tristate(
2922        &mut args,
2923        "ignore-identical-snapshots",
2924        policy.ignore_identical_snapshots,
2925    );
2926    push_valued_tristate(&mut args, "ignore-file-errors", policy.ignore_file_errors);
2927    push_valued_tristate(&mut args, "ignore-dir-errors", policy.ignore_dir_errors);
2928    push_valued_tristate(
2929        &mut args,
2930        "ignore-unknown-types",
2931        policy.ignore_unknown_types,
2932    );
2933    if let Some(n) = policy.max_parallel_snapshots {
2934        args.push("--max-parallel-snapshots".into());
2935        args.push(n.to_string());
2936    }
2937    if let Some(n) = policy.max_parallel_file_reads {
2938        args.push("--max-parallel-file-reads".into());
2939        args.push(n.to_string());
2940    }
2941    if let Some(n) = policy.keep_latest {
2942        args.push("--keep-latest".into());
2943        args.push(n.to_string());
2944    }
2945    if let Some(n) = policy.keep_hourly {
2946        args.push("--keep-hourly".into());
2947        args.push(n.to_string());
2948    }
2949    if let Some(n) = policy.keep_daily {
2950        args.push("--keep-daily".into());
2951        args.push(n.to_string());
2952    }
2953    if let Some(n) = policy.keep_weekly {
2954        args.push("--keep-weekly".into());
2955        args.push(n.to_string());
2956    }
2957    if let Some(n) = policy.keep_monthly {
2958        args.push("--keep-monthly".into());
2959        args.push(n.to_string());
2960    }
2961    if let Some(n) = policy.keep_annual {
2962        args.push("--keep-annual".into());
2963        args.push(n.to_string());
2964    }
2965    args.extend(policy.extra_args.iter().cloned());
2966    args
2967}
2968
2969/// Build the args for `kopia server start` (everything except the secret password,
2970/// which [`KopiaClient::server_start`] appends at exec time). Pure and unit-testable.
2971///
2972/// Always emits `--insecure` (no in-pod TLS; the user's ingress terminates TLS).
2973/// [`ServerAuthMode::Password`] emits `--server-username`; [`ServerAuthMode::None`]
2974/// emits `--without-password`.
2975fn server_start_args(spec: &ServerStartSpec) -> Vec<String> {
2976    let mut args = vec![
2977        "server".into(),
2978        "start".into(),
2979        "--address".into(),
2980        spec.address.clone(),
2981        // No in-pod TLS — this is kopia's *no-TLS* switch, required in every mode.
2982        "--insecure".into(),
2983    ];
2984    if spec.ui {
2985        args.push("--ui".into());
2986    }
2987    match &spec.auth {
2988        ServerAuthMode::Password { username } => {
2989            args.push("--server-username".into());
2990            args.push(username.clone());
2991        }
2992        ServerAuthMode::None => {
2993            args.push("--without-password".into());
2994            // kopia 0.23+ refuses to bind a non-loopback address with
2995            // `--insecure --without-password` unless this escape hatch is set
2996            // (it is exactly the "exposed unauthenticated server" the project gates
2997            // behind `acknowledgeInsecure`). We always bind `0.0.0.0` so the Service
2998            // can reach the server, so the flag is required here.
2999            args.push("--allow-extremely-dangerous-unauthenticated-server-on-the-network".into());
3000        }
3001    }
3002    args
3003}
3004
3005/// Extract the JSON result from kopia stdout. kopia prints a single JSON object
3006/// or array; progress goes to stderr. We find the first `{` or `[` and return
3007/// the trimmed remainder, which is the JSON value. Returns `None` if stdout
3008/// contains no `{`/`[`.
3009fn extract_json(stdout: &str) -> Option<&str> {
3010    let trimmed = stdout.trim();
3011    let start = trimmed.find(['{', '['])?;
3012    Some(trimmed[start..].trim())
3013}
3014
3015#[cfg(test)]
3016mod tests;