feat(kms): add backup manifest and responsibility contract types (#5483)

Contract-only module for KMS backup/restore (no handler or backend
wiring): versioned manifest schema with completeness marker and sealed
digest, the (backend, at-rest protection) responsibility matrix, typed
fail-closed errors, and the zero-write restore dry-run report. Fields
whose shape depends on in-flight contracts are reserved and reject data
in format version 1.
This commit is contained in:
Zhengchao An
2026-07-31 01:49:21 +08:00
committed by GitHub
parent 7051a5ce41
commit b457c6abcc
8 changed files with 1890 additions and 6 deletions
+11 -6
View File
@@ -69,11 +69,14 @@ fn validate_key_id(key_id: &str) -> Result<()> {
}
const LOCAL_KMS_MASTER_KEY_SALT_FILE: &str = ".master-key.salt";
const LOCAL_KMS_MASTER_KEY_SALT_LEN: usize = 16;
const LOCAL_KMS_MASTER_KEY_LEN: usize = 32;
const LOCAL_KMS_ARGON2_M_COST_KIB: u32 = 19 * 1024;
const LOCAL_KMS_ARGON2_T_COST: u32 = 2;
const LOCAL_KMS_ARGON2_P_COST: u32 = 1;
// The KDF parameters are pub(crate) so the backup manifest contract
// (`crate::backup`) records the exact compiled-in derivation instead of a
// copy that could drift.
pub(crate) const LOCAL_KMS_MASTER_KEY_SALT_LEN: usize = 16;
pub(crate) const LOCAL_KMS_MASTER_KEY_LEN: usize = 32;
pub(crate) const LOCAL_KMS_ARGON2_M_COST_KIB: u32 = 19 * 1024;
pub(crate) const LOCAL_KMS_ARGON2_T_COST: u32 = 2;
pub(crate) const LOCAL_KMS_ARGON2_P_COST: u32 = 1;
/// Strict matcher for leftover commit temp files (`<prefix>.tmp-<uuid>`).
///
@@ -396,9 +399,11 @@ pub struct LocalKmsClient {
key_write_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
// pub(crate) so the backup contract tests can anchor the manifest's
// protection-state wire names against the marker values written to disk.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum StoredKeyProtection {
pub(crate) enum StoredKeyProtection {
#[default]
LegacyUnspecified,
EncryptedMasterKey,
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Backup responsibility matrix: what a RustFS backup bundle owns per backend.
//!
//! The matrix is two-dimensional on purpose: responsibility is a function of
//! the backend *and* its at-rest protection state, not of the backend alone.
//! This keeps the schema stable when a backend changes protection direction —
//! switching Vault KV2 between storage-only and Transit-wrapped operation
//! selects a different existing row instead of changing the contract.
//!
//! These enums are backup-domain contract types. Once the backlog#1571
//! capability-discovery contract lands, the discovery surface is expected to
//! converge on (or map onto) the states defined here.
use serde::{Deserialize, Serialize};
/// Backend discriminant recorded in a backup manifest.
///
/// Wire names are aligned with [`crate::config::BackendConfig`] and
/// [`crate::config::KmsBackend`] (including the legacy `Vault` alias) so that
/// a manifest and a persisted KMS configuration never disagree about how the
/// same backend is spelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackupBackendKind {
/// Local file-based backend.
Local,
/// Vault KV v2 storage backend.
#[serde(rename = "VaultKV2", alias = "Vault")]
VaultKv2,
/// Vault Transit backend.
VaultTransit,
/// Static single-key backend.
Static,
}
/// At-rest protection state of master key material, as observed at snapshot
/// time.
///
/// The first three states mirror the Local backend's on-disk protection
/// marker (`StoredKeyProtection` in `backends/local.rs`, kebab-case wire
/// names). The remaining states describe the non-local backends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AtRestProtection {
/// Local key files AEAD-encrypted under the Argon2id-derived master key.
EncryptedMasterKey,
/// Local development-only plaintext key files. Such files must never
/// enter a bundle as-is; bundle artifacts are always re-wrapped under the
/// backup KEK.
PlaintextDevOnly,
/// Pre-beta.9 local key files without a protection marker; the effective
/// mode is resolved at read time. Treated like [`Self::PlaintextDevOnly`]
/// for bundling purposes: re-wrap is mandatory.
LegacyUnspecified,
/// Vault KV2 as currently shipped: material confidentiality relies on
/// Vault ACLs, KV2 at-rest encryption, and TLS only (the backend reports
/// `at_rest_protection = "vault-kv2-acl"`); RustFS applies no
/// cryptographic wrapping of its own.
StorageOnly,
/// Vault KV2 with material wrapped by Vault Transit before storage. Not
/// produced by any current backend; the row exists so a future direction
/// change selects a state instead of changing the schema.
TransitWrapped,
/// Vault Transit: the cryptographic root lives in Vault and is not
/// exportable. RustFS can only ever own metadata and references.
ExternalNonExportable,
/// Static backend: the secret is delivered externally at startup and
/// RustFS persists no key material at all.
ExternalSecretDelivery,
}
/// What a RustFS backup bundle is responsible for, per (backend, protection)
/// combination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BackupResponsibility {
/// The bundle carries the complete recoverable state: encrypted key
/// material for every version, salt, metadata, and configuration.
///
/// Restore precondition for the Local backend: the operator re-supplies
/// the master key out of band. The master key itself is outside the
/// backup domain; the manifest stores at most an opaque verifier.
FullMaterial,
/// The bundle carries only non-sensitive references and verification
/// information. The source of truth is external secret delivery and the
/// operator re-provides the secret during restore. Embedding the secret
/// itself in a bundle is forbidden.
ReferenceOnly,
/// The bundle carries RustFS-side metadata, configuration references and
/// verification data, while the cryptographic root is protected by the
/// external system's native snapshot/disaster-recovery flow (Vault/HSM).
/// Restore must re-establish the external trust root first.
MetadataPlusExternalRoot,
}
impl BackupResponsibility {
/// Resolve the responsibility matrix for one (backend, protection) cell.
///
/// Returns `None` for combinations that no supported deployment can
/// produce; manifests declaring such a combination are rejected as
/// corrupted. This function is total and the unit tests anchor every
/// cell, so any change to the matrix is a deliberate contract change.
pub fn for_backend(backend: BackupBackendKind, protection: AtRestProtection) -> Option<Self> {
use AtRestProtection::*;
use BackupBackendKind::*;
match (backend, protection) {
(Local, EncryptedMasterKey | PlaintextDevOnly | LegacyUnspecified) => Some(Self::FullMaterial),
(Local, _) => None,
// Storage-only KV2 offers no external cryptographic root, so the
// bundle must own the material (re-wrapped under the backup KEK).
(VaultKv2, StorageOnly) => Some(Self::FullMaterial),
(VaultKv2, TransitWrapped) => Some(Self::MetadataPlusExternalRoot),
(VaultKv2, _) => None,
(VaultTransit, ExternalNonExportable) => Some(Self::MetadataPlusExternalRoot),
(VaultTransit, _) => None,
(Static, ExternalSecretDelivery) => Some(Self::ReferenceOnly),
(Static, _) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::KmsBackend;
fn json<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serialization should succeed")
}
#[test]
fn backend_kind_wire_names_match_kms_backend() {
let pairs = [
(BackupBackendKind::Local, KmsBackend::Local),
(BackupBackendKind::VaultKv2, KmsBackend::VaultKv2),
(BackupBackendKind::VaultTransit, KmsBackend::VaultTransit),
(BackupBackendKind::Static, KmsBackend::Static),
];
for (backup_kind, config_kind) in pairs {
assert_eq!(json(&backup_kind), json(&config_kind), "wire name drifted for {backup_kind:?}");
}
}
#[test]
fn backend_kind_accepts_legacy_vault_alias() {
let decoded: BackupBackendKind = serde_json::from_str("\"Vault\"").expect("legacy alias should decode");
assert_eq!(decoded, BackupBackendKind::VaultKv2);
}
#[test]
fn responsibility_matrix_is_anchored_cell_by_cell() {
use AtRestProtection::*;
use BackupBackendKind::*;
use BackupResponsibility::*;
// Every (backend, protection) cell, exhaustively. Changing any row is
// a contract change and must be made here consciously.
let matrix = [
(Local, EncryptedMasterKey, Some(FullMaterial)),
(Local, PlaintextDevOnly, Some(FullMaterial)),
(Local, LegacyUnspecified, Some(FullMaterial)),
(Local, StorageOnly, None),
(Local, TransitWrapped, None),
(Local, ExternalNonExportable, None),
(Local, ExternalSecretDelivery, None),
(VaultKv2, EncryptedMasterKey, None),
(VaultKv2, PlaintextDevOnly, None),
(VaultKv2, LegacyUnspecified, None),
(VaultKv2, StorageOnly, Some(FullMaterial)),
(VaultKv2, TransitWrapped, Some(MetadataPlusExternalRoot)),
(VaultKv2, ExternalNonExportable, None),
(VaultKv2, ExternalSecretDelivery, None),
(VaultTransit, EncryptedMasterKey, None),
(VaultTransit, PlaintextDevOnly, None),
(VaultTransit, LegacyUnspecified, None),
(VaultTransit, StorageOnly, None),
(VaultTransit, TransitWrapped, None),
(VaultTransit, ExternalNonExportable, Some(MetadataPlusExternalRoot)),
(VaultTransit, ExternalSecretDelivery, None),
(Static, EncryptedMasterKey, None),
(Static, PlaintextDevOnly, None),
(Static, LegacyUnspecified, None),
(Static, StorageOnly, None),
(Static, TransitWrapped, None),
(Static, ExternalNonExportable, None),
(Static, ExternalSecretDelivery, Some(ReferenceOnly)),
];
assert_eq!(matrix.len(), 28, "matrix must stay exhaustive: 4 backends x 7 protection states");
for (backend, protection, expected) in matrix {
assert_eq!(
BackupResponsibility::for_backend(backend, protection),
expected,
"matrix cell drifted for ({backend:?}, {protection:?})"
);
}
}
#[test]
fn local_protection_wire_names_match_stored_key_protection() {
use crate::backends::local::StoredKeyProtection;
// The manifest must record exactly the marker values the Local
// backend writes to disk, or a restore could misread protection.
let pairs = [
(AtRestProtection::EncryptedMasterKey, StoredKeyProtection::EncryptedMasterKey),
(AtRestProtection::PlaintextDevOnly, StoredKeyProtection::PlaintextDevOnly),
(AtRestProtection::LegacyUnspecified, StoredKeyProtection::LegacyUnspecified),
];
for (backup_state, stored_state) in pairs {
assert_eq!(json(&backup_state), json(&stored_state), "wire name drifted for {backup_state:?}");
}
}
#[test]
fn responsibility_wire_names_are_frozen() {
assert_eq!(json(&BackupResponsibility::FullMaterial), "\"full-material\"");
assert_eq!(json(&BackupResponsibility::ReferenceOnly), "\"reference-only\"");
assert_eq!(json(&BackupResponsibility::MetadataPlusExternalRoot), "\"metadata-plus-external-root\"");
}
}
+280
View File
@@ -0,0 +1,280 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Restore dry-run report contract.
//!
//! A restore dry-run is a zero-write preflight: it evaluates a bundle against
//! a target and reports every blocker, conflict, and external dependency
//! mismatch without modifying the target in any way. The report itself is
//! plain data — producing, serializing, or discarding it has no side effects,
//! and an implementation that writes anything during a dry-run violates this
//! contract. All values in a report are identifiers and references; secrets,
//! tokens, and key material never appear in it.
use crate::backup::error::BackupError;
use serde::{Deserialize, Serialize};
/// Machine-readable category of a restore blocker.
///
/// The first six codes mirror the [`BackupError`] variants; the remaining
/// codes cover preflight conditions that are not bundle defects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestoreBlockerCode {
/// The bundle failed structural or integrity validation.
BundleCorrupted,
/// The manifest input ended prematurely.
BundleTruncated,
/// The manifest format version is unknown to this build.
UnknownFormatVersion,
/// The supplied backup KEK does not match the bundle's KEK.
WrongBackupKek,
/// A required artifact is absent from the bundle.
MissingArtifact,
/// The bundle has no completeness marker or is marked in-progress.
IncompleteBundle,
/// The target backend cannot satisfy the bundle's responsibility model.
UnsupportedBackend,
/// The bundle was produced by a different deployment than the target and
/// no explicit cross-deployment authorization applies.
DeploymentMismatch,
/// An external dependency (Vault cluster, mount, Transit key, ...) that
/// the bundle references is unreachable or missing.
ExternalDependencyUnavailable,
}
/// One condition that forbids the restore outright.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RestoreBlocker {
/// Machine-readable category.
pub code: RestoreBlockerCode,
/// Human-readable detail. Identifiers only; never secrets or material.
pub detail: String,
}
impl From<&BackupError> for RestoreBlocker {
fn from(error: &BackupError) -> Self {
let code = match error {
BackupError::Corrupted { .. } => RestoreBlockerCode::BundleCorrupted,
BackupError::Truncated { .. } => RestoreBlockerCode::BundleTruncated,
BackupError::UnknownVersion { .. } => RestoreBlockerCode::UnknownFormatVersion,
BackupError::WrongKek { .. } => RestoreBlockerCode::WrongBackupKek,
BackupError::MissingArtifact { .. } => RestoreBlockerCode::MissingArtifact,
BackupError::IncompleteBundle { .. } => RestoreBlockerCode::IncompleteBundle,
};
Self {
code,
detail: error.to_string(),
}
}
}
/// Kind of a conflict between bundle state and existing target state.
///
/// Restore is non-destructive by default: every conflict blocks the restore
/// unless an explicit, audited conflict policy resolves it. Silent overwrite
/// or merge is never an option.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestoreConflictKind {
/// The target already has a key with this stable id.
KeyAlreadyExists,
/// Restoring would lower a key version the target has already observed.
VersionRegression,
/// Restoring would lower the snapshot generation the target has already
/// observed.
GenerationRegression,
/// Restoring would revive a key the target has deleted or scheduled for
/// deletion.
StateRegression,
}
/// One conflict with existing target state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RestoreConflict {
/// Stable key id the conflict concerns.
pub key_id: String,
/// Machine-readable category.
pub kind: RestoreConflictKind,
/// Human-readable detail. Identifiers only; never secrets or material.
pub detail: String,
}
/// A mismatch between an external dependency reference recorded in the bundle
/// and what the target environment observes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalDependencyMismatch {
/// Which dependency is affected (for example a Vault mount or Transit
/// key name). References only; never credentials.
pub dependency: String,
/// The value the bundle recorded.
pub expected: String,
/// The value the target environment reports.
pub observed: String,
}
/// Result of a restore dry-run preflight.
///
/// # Zero-write contract
///
/// A dry-run must not write to the target: no staging directories, no
/// repaired records, no metadata fixes triggered along the read path. The
/// report is pure data over values already known to the caller.
///
/// ```
/// use rustfs_kms::backup::{RestoreBlocker, RestoreBlockerCode, RestoreDryRunReport};
///
/// let clean = RestoreDryRunReport {
/// backup_id: "backup-0001".to_string(),
/// target_deployment_identity: "deployment-a".to_string(),
/// blockers: Vec::new(),
/// conflicts: Vec::new(),
/// external_mismatches: Vec::new(),
/// };
/// assert!(clean.restore_permitted());
///
/// let blocked = RestoreDryRunReport {
/// blockers: vec![RestoreBlocker {
/// code: RestoreBlockerCode::IncompleteBundle,
/// detail: "manifest has no completeness marker".to_string(),
/// }],
/// ..clean
/// };
/// assert!(!blocked.restore_permitted());
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RestoreDryRunReport {
/// Identifier of the evaluated bundle.
pub backup_id: String,
/// Identity of the restore target the bundle was evaluated against.
pub target_deployment_identity: String,
/// Conditions that forbid the restore outright.
pub blockers: Vec<RestoreBlocker>,
/// Conflicts with existing target state.
pub conflicts: Vec<RestoreConflict>,
/// External dependency mismatches.
pub external_mismatches: Vec<ExternalDependencyMismatch>,
}
impl RestoreDryRunReport {
/// Whether the restore may proceed: true only when the preflight found
/// no blockers, no conflicts, and no external dependency mismatches.
pub fn restore_permitted(&self) -> bool {
self.blockers.is_empty() && self.conflicts.is_empty() && self.external_mismatches.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_report() -> RestoreDryRunReport {
RestoreDryRunReport {
backup_id: "backup-0001".to_string(),
target_deployment_identity: "deployment-b".to_string(),
blockers: vec![RestoreBlocker {
code: RestoreBlockerCode::DeploymentMismatch,
detail: "bundle was produced by deployment-a".to_string(),
}],
conflicts: vec![RestoreConflict {
key_id: "object-key".to_string(),
kind: RestoreConflictKind::VersionRegression,
detail: "target observed version 5, bundle carries version 3".to_string(),
}],
external_mismatches: vec![ExternalDependencyMismatch {
dependency: "vault transit key rustfs-master".to_string(),
expected: "min_version=2".to_string(),
observed: "min_version=4".to_string(),
}],
}
}
#[test]
fn report_round_trips_through_json() {
let report = sample_report();
let json = serde_json::to_string(&report).expect("serialization should succeed");
let decoded: RestoreDryRunReport = serde_json::from_str(&json).expect("deserialization should succeed");
assert_eq!(decoded, report);
}
#[test]
fn restore_permitted_requires_every_section_empty() {
assert!(!sample_report().restore_permitted());
let clean = RestoreDryRunReport {
blockers: Vec::new(),
conflicts: Vec::new(),
external_mismatches: Vec::new(),
..sample_report()
};
assert!(clean.restore_permitted());
for section in 0..3 {
let mut report = clean.clone();
match section {
0 => report.blockers = sample_report().blockers,
1 => report.conflicts = sample_report().conflicts,
_ => report.external_mismatches = sample_report().external_mismatches,
}
assert!(!report.restore_permitted(), "section {section} alone must block the restore");
}
}
#[test]
fn every_backup_error_maps_to_a_blocker_code() {
let cases = [
(BackupError::corrupted("x"), RestoreBlockerCode::BundleCorrupted),
(BackupError::truncated("x"), RestoreBlockerCode::BundleTruncated),
(
BackupError::UnknownVersion { found: 2, supported: 1 },
RestoreBlockerCode::UnknownFormatVersion,
),
(
BackupError::WrongKek {
required_kek_id: "a".to_string(),
required_kek_version: 1,
supplied_kek_id: "b".to_string(),
supplied_kek_version: 1,
},
RestoreBlockerCode::WrongBackupKek,
),
(BackupError::missing_artifact("key-material"), RestoreBlockerCode::MissingArtifact),
(BackupError::incomplete_bundle("x"), RestoreBlockerCode::IncompleteBundle),
];
for (error, expected_code) in cases {
let blocker = RestoreBlocker::from(&error);
assert_eq!(blocker.code, expected_code, "wrong code for {error:?}");
assert_eq!(blocker.detail, error.to_string());
}
}
/// The zero-write contract in practice: a report is plain serializable
/// data with no handles, no I/O, and no drop side effects.
#[test]
fn report_types_are_plain_data() {
fn assert_plain_data<T>()
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static,
{
}
assert_plain_data::<RestoreDryRunReport>();
assert_plain_data::<RestoreBlocker>();
assert_plain_data::<RestoreConflict>();
assert_plain_data::<ExternalDependencyMismatch>();
}
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Typed failures for the backup/restore bundle contract.
use thiserror::Error;
/// Typed failures raised while decoding or validating a backup bundle.
///
/// Every variant is a fail-closed condition: a restore surface observing any
/// of them must abort before touching target state. Messages carry only
/// identifiers (backup ids, KEK ids, artifact kinds and paths) — never key
/// material, bundle plaintext, or credentials.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum BackupError {
/// Manifest or bundle content fails structural, schema, or integrity
/// validation (unknown fields, duplicate fields, digest mismatch,
/// contradictory responsibility declarations, ...).
#[error("backup bundle corrupted: {reason}")]
Corrupted { reason: String },
/// Input ended before a complete manifest could be decoded.
#[error("backup manifest truncated: {reason}")]
Truncated { reason: String },
/// Manifest declares a format version this build does not understand.
/// Unknown versions are always rejected; there is no best-effort read.
#[error("unknown backup manifest format version {found} (this build supports version {supported})")]
UnknownVersion { found: u32, supported: u32 },
/// Bundle is protected by a different backup KEK than the one supplied.
#[error(
"backup bundle requires KEK '{required_kek_id}' version {required_kek_version}; \
supplied KEK '{supplied_kek_id}' version {supplied_kek_version} cannot open it"
)]
WrongKek {
required_kek_id: String,
required_kek_version: u32,
supplied_kek_id: String,
supplied_kek_version: u32,
},
/// Manifest requires an artifact that is not present in the bundle.
#[error("backup bundle is missing a required artifact: {artifact}")]
MissingArtifact { artifact: String },
/// Bundle has no completeness marker or records an in-progress state.
/// A bundle that never reached its completeness marker must never be
/// restored, regardless of how much of it is readable.
#[error("backup bundle is incomplete ({reason}); incomplete bundles must never be restored")]
IncompleteBundle { reason: String },
}
impl BackupError {
/// Create a corrupted-bundle error.
pub fn corrupted<S: Into<String>>(reason: S) -> Self {
Self::Corrupted { reason: reason.into() }
}
/// Create a truncated-manifest error.
pub fn truncated<S: Into<String>>(reason: S) -> Self {
Self::Truncated { reason: reason.into() }
}
/// Create an incomplete-bundle error.
pub fn incomplete_bundle<S: Into<String>>(reason: S) -> Self {
Self::IncompleteBundle { reason: reason.into() }
}
/// Create a missing-artifact error.
pub fn missing_artifact<S: Into<String>>(artifact: S) -> Self {
Self::MissingArtifact {
artifact: artifact.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::KmsError;
#[test]
fn backup_errors_convert_into_kms_error_transparently() {
let error = BackupError::corrupted("manifest digest mismatch");
let kms_error: KmsError = error.clone().into();
assert_eq!(kms_error.to_string(), error.to_string());
assert!(matches!(kms_error, KmsError::Backup(inner) if inner == error));
}
#[test]
fn error_messages_carry_identifiers_only() {
// The display strings must stay descriptive without ever embedding
// material or bundle plaintext; each variant only interpolates the
// identifiers below.
let wrong_kek = BackupError::WrongKek {
required_kek_id: "backup-kek-1".to_string(),
required_kek_version: 3,
supplied_kek_id: "backup-kek-2".to_string(),
supplied_kek_version: 1,
};
assert_eq!(
wrong_kek.to_string(),
"backup bundle requires KEK 'backup-kek-1' version 3; supplied KEK 'backup-kek-2' version 1 cannot open it"
);
let unknown = BackupError::UnknownVersion { found: 9, supported: 1 };
assert_eq!(
unknown.to_string(),
"unknown backup manifest format version 9 (this build supports version 1)"
);
assert_eq!(
BackupError::missing_artifact("key-material").to_string(),
"backup bundle is missing a required artifact: key-material"
);
assert_eq!(
BackupError::incomplete_bundle("manifest has no completeness marker").to_string(),
"backup bundle is incomplete (manifest has no completeness marker); incomplete bundles must never be restored"
);
}
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Backup/restore contract types for KMS state.
//!
//! This module is contract-only: it defines the versioned backup manifest,
//! the per-backend responsibility matrix, typed failure modes, and the
//! restore dry-run report. Nothing here is wired into handlers or backends;
//! backup export, restore orchestration, and the admin API build on these
//! types in follow-up changes.
//!
//! # Bundle model
//!
//! A backup bundle is a set of AEAD-encrypted artifacts described by a single
//! [`BackupManifest`]. All state in a bundle belongs to one snapshot
//! generation — there is no partially consistent bundle. The bundle is
//! protected by a backup KEK that is deliberately outside the business KMS
//! trust hierarchy, and the manifest is sealed with a completeness marker and
//! a final digest; a bundle that never reached its marker is permanently
//! non-restorable.
//!
//! # Restore ordering
//!
//! Restore implementations must follow this order: re-establish the external
//! trust root first (Vault/HSM native restore where one exists), then
//! material and version records into staging, then metadata and
//! configuration, then verification, and only then an explicit atomic
//! cutover. A dry-run ([`RestoreDryRunReport`]) performs zero writes.
//!
//! # Deliberately unfrozen
//!
//! Fields whose shape depends on contracts still in flight are reserved
//! rather than guessed (see [`ReservedSlot`]): the per-key version inventory
//! (backlog#1565) and capability discovery (backlog#1571). Alias and policy
//! artifacts are reserved names for features that do not exist yet. Reserved
//! slots reject data in format version 1 and become real types in a later
//! format version.
mod capability;
mod dry_run;
mod error;
mod manifest;
pub use capability::{AtRestProtection, BackupBackendKind, BackupResponsibility};
pub use dry_run::{
ExternalDependencyMismatch, RestoreBlocker, RestoreBlockerCode, RestoreConflict, RestoreConflictKind, RestoreDryRunReport,
};
pub use error::BackupError;
pub use manifest::{
AeadAlgorithm, ArtifactDescriptor, ArtifactKind, BackupKekDescriptor, BackupManifest, CompletenessState, ContentDigest,
DigestAlgorithm, LocalKdfDescriptor, LocalKeyDerivation, ReservedSlot,
};
+4
View File
@@ -124,6 +124,10 @@ pub enum KmsError {
/// Requested master key version has no persisted material for the key
#[error("Key version {version} not found for key {key_id}")]
KeyVersionNotFound { key_id: String, version: u32 },
/// Backup/restore bundle contract violation; see [`crate::backup::BackupError`]
#[error(transparent)]
Backup(#[from] crate::backup::BackupError),
}
impl KmsError {
+1
View File
@@ -66,6 +66,7 @@
// Core modules
pub mod api_types;
pub mod backends;
pub mod backup;
mod cache;
pub mod config;
mod encryption;