Skip to main content

rc_core/admin/
kms_diagnostic.rs

1//! Non-exporting SSE-KMS object round-trip diagnostics.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::time::{Duration, Instant};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::{Error, RemotePath, Result};
9
10/// Fixed upper bound for internally generated diagnostic plaintext.
11pub const KMS_DIAGNOSTIC_CONTENT_BYTES: usize = 4096;
12
13/// Adapter boundary for the three sensitive object operations used by the diagnostic.
14#[async_trait]
15pub trait KmsDiagnosticStore: Send + Sync {
16    /// Upload bounded plaintext with explicit SSE-KMS encryption.
17    async fn put_kms_diagnostic_object(
18        &self,
19        path: &RemotePath,
20        content: Zeroizing<Vec<u8>>,
21        key_id: &str,
22    ) -> Result<()>;
23
24    /// Read and decrypt bounded diagnostic plaintext.
25    async fn get_kms_diagnostic_object(
26        &self,
27        path: &RemotePath,
28        max_bytes: usize,
29    ) -> Result<Zeroizing<Vec<u8>>>;
30
31    /// Permanently remove the temporary object, including from versioned buckets.
32    async fn delete_kms_diagnostic_object(&self, path: &RemotePath) -> Result<()>;
33}
34
35/// Safe phase identifier for a failed diagnostic.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum KmsRoundTripPhase {
38    Write,
39    Read,
40    Verify,
41    Cleanup,
42}
43
44/// Stable error class retained after adapter diagnostics are discarded.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum KmsRoundTripErrorClass {
47    Auth,
48    NotFound,
49    Network,
50    Conflict,
51    General,
52}
53
54/// Safe timing metadata for a completed diagnostic.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct KmsRoundTripTimings {
57    pub write_ms: u64,
58    pub read_ms: u64,
59    pub cleanup_ms: u64,
60    pub total_ms: u64,
61}
62
63/// Successful diagnostic result. Temporary names and content are intentionally absent.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct KmsRoundTripReport {
66    pub bucket: String,
67    pub key_id: String,
68    pub passed: bool,
69    pub cleanup_passed: bool,
70    pub timings: KmsRoundTripTimings,
71}
72
73/// Sanitized failure that preserves exit-code classification and cleanup state.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct KmsRoundTripError {
76    pub phase: KmsRoundTripPhase,
77    pub class: KmsRoundTripErrorClass,
78    pub cleanup_failed: bool,
79}
80
81impl KmsRoundTripError {
82    /// Convert the safe failure into the shared CLI error taxonomy.
83    pub fn into_core_error(self) -> Error {
84        let message = self.to_string();
85        match self.class {
86            KmsRoundTripErrorClass::Auth => Error::Auth(message),
87            KmsRoundTripErrorClass::NotFound => Error::NotFound(message),
88            KmsRoundTripErrorClass::Network => Error::Network(message),
89            KmsRoundTripErrorClass::Conflict => Error::Conflict(message),
90            KmsRoundTripErrorClass::General => Error::General(message),
91        }
92    }
93
94    fn safe_message(self) -> &'static str {
95        match (self.phase, self.cleanup_failed) {
96            (KmsRoundTripPhase::Write, true) => {
97                "KMS round-trip SSE-KMS write failed; temporary object cleanup also failed"
98            }
99            (KmsRoundTripPhase::Read, true) => {
100                "KMS round-trip read/decrypt failed; temporary object cleanup also failed"
101            }
102            (KmsRoundTripPhase::Verify, true) => {
103                "KMS round-trip verification mismatched; temporary object cleanup also failed"
104            }
105            (KmsRoundTripPhase::Write, false) => "KMS round-trip SSE-KMS write failed",
106            (KmsRoundTripPhase::Read, false) => "KMS round-trip read/decrypt failed",
107            (KmsRoundTripPhase::Verify, false) => "KMS round-trip verification mismatched",
108            (KmsRoundTripPhase::Cleanup, _) => {
109                "KMS round-trip verification passed, but temporary object cleanup failed"
110            }
111        }
112    }
113}
114
115impl std::fmt::Display for KmsRoundTripError {
116    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        formatter.write_str(self.safe_message())
118    }
119}
120
121impl std::error::Error for KmsRoundTripError {}
122
123/// Run one bounded SSE-KMS write/read/verify/delete diagnostic.
124pub async fn run_kms_round_trip(
125    store: &dyn KmsDiagnosticStore,
126    bucket: &str,
127    key_id: &str,
128) -> std::result::Result<KmsRoundTripReport, KmsRoundTripError> {
129    let mut content = Zeroizing::new(vec![0_u8; KMS_DIAGNOSTIC_CONTENT_BYTES]);
130    getrandom::fill(content.as_mut_slice()).map_err(|_| KmsRoundTripError {
131        phase: KmsRoundTripPhase::Write,
132        class: KmsRoundTripErrorClass::General,
133        cleanup_failed: false,
134    })?;
135
136    let mut object_id = [0_u8; 16];
137    getrandom::fill(&mut object_id).map_err(|_| KmsRoundTripError {
138        phase: KmsRoundTripPhase::Write,
139        class: KmsRoundTripErrorClass::General,
140        cleanup_failed: false,
141    })?;
142    let path = RemotePath::new(
143        "",
144        bucket,
145        format!(
146            ".rustfs-kms-diagnostic/{:032x}",
147            u128::from_be_bytes(object_id)
148        ),
149    );
150
151    let total_started = Instant::now();
152    let write_started = Instant::now();
153    let write_result = store
154        .put_kms_diagnostic_object(&path, Zeroizing::new(content.to_vec()), key_id)
155        .await;
156    let write_ms = duration_ms(write_started.elapsed());
157
158    let read_started = Instant::now();
159    let primary_failure = match write_result {
160        Err(error) => Some((KmsRoundTripPhase::Write, classify_and_discard_error(error))),
161        Ok(()) => match store
162            .get_kms_diagnostic_object(&path, KMS_DIAGNOSTIC_CONTENT_BYTES)
163            .await
164        {
165            Err(error) => Some((KmsRoundTripPhase::Read, classify_and_discard_error(error))),
166            Ok(actual) if actual.as_slice() != content.as_slice() => {
167                Some((KmsRoundTripPhase::Verify, KmsRoundTripErrorClass::General))
168            }
169            Ok(_) => None,
170        },
171    };
172    let read_ms = duration_ms(read_started.elapsed());
173
174    let cleanup_started = Instant::now();
175    let cleanup_failure = store
176        .delete_kms_diagnostic_object(&path)
177        .await
178        .err()
179        .map(classify_and_discard_error);
180    let cleanup_ms = duration_ms(cleanup_started.elapsed());
181
182    match (primary_failure, cleanup_failure) {
183        (Some((phase, class)), cleanup) => Err(KmsRoundTripError {
184            phase,
185            class,
186            cleanup_failed: cleanup.is_some(),
187        }),
188        (None, Some(class)) => Err(KmsRoundTripError {
189            phase: KmsRoundTripPhase::Cleanup,
190            class,
191            cleanup_failed: true,
192        }),
193        (None, None) => Ok(KmsRoundTripReport {
194            bucket: bucket.to_string(),
195            key_id: key_id.to_string(),
196            passed: true,
197            cleanup_passed: true,
198            timings: KmsRoundTripTimings {
199                write_ms,
200                read_ms,
201                cleanup_ms,
202                total_ms: duration_ms(total_started.elapsed()),
203            },
204        }),
205    }
206}
207
208fn duration_ms(duration: Duration) -> u64 {
209    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
210}
211
212fn classify_and_discard_error(mut error: Error) -> KmsRoundTripErrorClass {
213    match &mut error {
214        Error::Auth(message) => {
215            message.zeroize();
216            KmsRoundTripErrorClass::Auth
217        }
218        Error::NotFound(message) | Error::AliasNotFound(message) => {
219            message.zeroize();
220            KmsRoundTripErrorClass::NotFound
221        }
222        Error::VersionNotFound { path, version_id } | Error::DeleteMarker { path, version_id } => {
223            path.zeroize();
224            version_id.zeroize();
225            KmsRoundTripErrorClass::NotFound
226        }
227        Error::Network(message) => {
228            message.zeroize();
229            KmsRoundTripErrorClass::Network
230        }
231        Error::Conflict(message) | Error::AliasExists(message) => {
232            message.zeroize();
233            KmsRoundTripErrorClass::Conflict
234        }
235        Error::GovernanceDenied { path, version_id } => {
236            path.zeroize();
237            version_id.zeroize();
238            KmsRoundTripErrorClass::Conflict
239        }
240        Error::Config(message)
241        | Error::InvalidPath(message)
242        | Error::UnsupportedFeature(message)
243        | Error::General(message)
244        | Error::RequestRejected(message)
245        | Error::Interrupted(message) => {
246            message.zeroize();
247            KmsRoundTripErrorClass::General
248        }
249        Error::Io(_)
250        | Error::TomlParse(_)
251        | Error::TomlSerialize(_)
252        | Error::Json(_)
253        | Error::InvalidUrl(_) => KmsRoundTripErrorClass::General,
254    }
255}