Skip to main content

bestool_canopy/
backup.rs

1//! Backup-specific types that aren't part of canopy's wire contract.
2//!
3//! The request and response bodies for the backup endpoints are generated from
4//! canopy's OpenAPI document — see the [`schema`](crate::schema) module. This
5//! module holds the two shapes that don't map onto a schema type: the container
6//! credentials kopia's minio-go provider polls for, and the local result of a
7//! [`GET /backup-target`](crate::CanopyClient::backup_target) that folds the
8//! dormant device state into an enum.
9
10use http::StatusCode;
11use jiff::Timestamp;
12use miette::{IntoDiagnostic, Result};
13use serde::Serialize;
14
15use crate::{
16	Redacted,
17	schema::{BackupTarget, CredentialProcessOutput},
18};
19
20/// Creds in the ECS container-credentials shape kopia's minio-go provider polls
21/// for: note **`Token`** (not `SessionToken`), and `Expiration` as RFC3339 `Z`.
22#[derive(Debug, Clone, Serialize)]
23#[serde(rename_all = "PascalCase")]
24pub struct ContainerCreds {
25	pub access_key_id: String,
26	pub secret_access_key: Redacted<String>,
27	pub token: Redacted<String>,
28	pub expiration: Timestamp,
29}
30
31impl From<&CredentialProcessOutput> for ContainerCreds {
32	fn from(c: &CredentialProcessOutput) -> Self {
33		Self {
34			access_key_id: c.access_key_id.clone(),
35			secret_access_key: c.secret_access_key.clone(),
36			token: c.session_token.clone(),
37			expiration: c.expiration,
38		}
39	}
40}
41
42/// Result of [`GET /backup-target`](crate::CanopyClient::backup_target): a live
43/// target, or the benign dormant state (the device is not yet authorised for
44/// backups — `412`/`409`).
45#[derive(Debug, Clone)]
46pub enum TargetOutcome {
47	Ready(BackupTarget),
48	Dormant,
49}
50
51impl TargetOutcome {
52	/// Interpret a [`backup_target`](crate::CanopyClient::backup_target) result:
53	/// a `412`/`409` (the device isn't yet authorised for backups) becomes
54	/// [`Dormant`](Self::Dormant), a target becomes [`Ready`](Self::Ready), and
55	/// any other error propagates.
56	pub fn from_result(result: bes_canopy_api::Result<BackupTarget>) -> Result<Self> {
57		match result {
58			Ok(target) => Ok(Self::Ready(target)),
59			Err(err)
60				if matches!(
61					err.status(),
62					Some(StatusCode::PRECONDITION_FAILED | StatusCode::CONFLICT)
63				) =>
64			{
65				Ok(Self::Dormant)
66			}
67			Err(err) => Err(err).into_diagnostic(),
68		}
69	}
70}
71
72#[cfg(test)]
73mod tests {
74	use serde_json::json;
75
76	use super::*;
77
78	#[test]
79	fn container_creds_translate_session_token_to_token() {
80		let creds: CredentialProcessOutput = serde_json::from_value(json!({
81			"Version": 1,
82			"AccessKeyId": "AKIA",
83			"SecretAccessKey": "secret",
84			"SessionToken": "session-token",
85			"Expiration": "2026-05-21T13:00:00Z",
86		}))
87		.unwrap();
88		let container = ContainerCreds::from(&creds);
89		let out = serde_json::to_value(&container).unwrap();
90		assert_eq!(
91			out,
92			json!({
93				"AccessKeyId": "AKIA",
94				"SecretAccessKey": "secret",
95				"Token": "session-token",
96				"Expiration": "2026-05-21T13:00:00Z",
97			})
98		);
99		// No SessionToken key leaks through.
100		assert!(out.get("SessionToken").is_none());
101	}
102
103	#[test]
104	fn redacted_debug_does_not_leak() {
105		let creds = ContainerCreds {
106			access_key_id: "AKIA".to_owned(),
107			secret_access_key: Redacted("aws-sk-value-123".to_owned()),
108			token: Redacted("aws-token-value-456".to_owned()),
109			expiration: "2026-05-21T13:00:00Z".parse().unwrap(),
110		};
111		let debug = format!("{creds:?}");
112		assert!(!debug.contains("aws-sk-value-123"));
113		assert!(!debug.contains("aws-token-value-456"));
114		assert!(debug.contains("<redacted>"));
115	}
116}