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