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