// 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.

//! Local backend restore: consume a sealed backup bundle into a key directory.
//!
//! The consumer side of [`crate::backup::local_export`]. The admin API is not
//! wired here — callers construct the request and supply the backup KEK and
//! the operator-re-supplied master key explicitly. The bundle source is
//! strictly read-only throughout: restore never writes to or deletes from it.
//!
//! # Four-phase protocol
//!
//! 1. **Dry-run (zero writes)** — [`dry_run_local_restore`] decodes the full
//!    bundle chain in memory, checks the KDF descriptor against this build,
//!    enumerates target conflicts, and verifies the operator-supplied master
//!    key against the manifest's one-way verifier. It writes nothing.
//! 2. **Staging** — decrypted artifacts are committed durably into the
//!    `.restore-staging/` subdirectory of the target. The leading dot and the
//!    directory shape keep it invisible to the backend's key scan and its
//!    orphan-temp matcher, so startup recovery can never delete staged state.
//!    Every record is decryption-probed with the derived master key before
//!    and after staging.
//! 3. **Commit marker + cutover** — the durably published
//!    `.restore-commit.json` marker is the single commit point. Cutover
//!    publishes each staged file into the target top level via the no-clobber
//!    [`durable_file::link_durably`] primitive (salt first, keys after), then
//!    durably removes the marker and drops the staging directory.
//! 4. **Crash re-entry** — before the marker, the target top level is
//!    untouched and a re-run starts over; with the marker published, backend
//!    startup fails closed and re-running the restore with the same bundle
//!    rolls the cutover forward, while [`abort_local_restore`] rolls it back.
//!    Every interruption converges to the complete old or complete new state.
//!
//! # Deliberately out of scope
//!
//! The `explicit-remap` conflict policy is not implemented: remapping stable
//! key ids requires proving that object envelopes migrate in lockstep, and a
//! bulk rekey is a non-goal of this change. Local has no rotation history
//! (backlog#1565), so per-key version and deletion-state regression cannot
//! become write operations under the empty-target policy; the snapshot
//! generation check freezes the injected observe-and-reject-strictly-lower
//! semantics for the admin layer.

use crate::backends::local::{
    LOCAL_KMS_MASTER_KEY_SALT_FILE, LOCAL_KMS_MASTER_KEY_SALT_LEN, LOCAL_RESTORE_COMMIT_MARKER_FILE, LocalKmsClient,
    STORED_MASTER_KEY_FORMAT_VERSION, StoredKeyProtection, UNKNOWN_STORED_KEY_PROTECTION, durable_file,
    has_unknown_protection_marker, is_orphan_commit_temp_name, stored_master_key_format_version, validate_key_id,
};
use crate::backup::capability::AtRestProtection;
use crate::backup::dry_run::{
    ExternalDependencyMismatch, RestoreBlocker, RestoreBlockerCode, RestoreConflict, RestoreConflictKind, RestoreDryRunReport,
};
use crate::backup::error::BackupError;
use crate::backup::local_export::{
    BackupKek, MASTER_KEY_VERIFIER_ARGON2ID_PREFIX, MASTER_KEY_VERIFIER_LEGACY_PREFIX, compute_master_key_verifier,
    decrypt_bundle_artifact, fsync_dir, read_local_bundle_manifest,
};
use crate::backup::manifest::{ArtifactKind, BackupManifest, ContentDigest, LocalKeyDerivation};
use crate::error::{KmsError, Result};
use aes_gcm::{
    Aes256Gcm, Nonce,
    aead::{Aead, KeyInit},
};
use base64_simd::STANDARD as BASE64;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::warn;
use zeroize::Zeroizing;

/// Name of the staging subdirectory inside the restore target.
const RESTORE_STAGING_DIR: &str = ".restore-staging";
/// Format version of the cutover commit marker.
const RESTORE_MARKER_FORMAT_VERSION: u32 = 1;
/// Sentinel key id for conflicts that concern the whole target rather than
/// one key (the snapshot generation).
const WHOLE_TARGET_CONFLICT_ID: &str = "*";

/// How restore resolves conflicts with existing target state.
///
/// Silent overwrite or merge is never an option; `explicit-remap` is
/// deliberately not offered (see the module docs).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestoreConflictPolicy {
    /// Never write to the target. The default: any actual restore attempt is
    /// refused, so mutating a key directory always requires the explicit
    /// opt-in below. Dry-runs are unaffected.
    #[default]
    Fail,
    /// Restore only into an empty target. A target counts as non-empty when
    /// it holds any key file, a KDF salt (an orphan salt would poison every
    /// key created later), a marker of a different restore, or any other
    /// unexpected entry; only leftovers of an interrupted run of this same
    /// restore are tolerated.
    RestoreIntoEmptyTarget,
}

/// Parameters of one restore (or dry-run) attempt.
///
/// `observed_generation` is injected by the caller, mirroring the export
/// request's `snapshot_generation`: the admin layer owns where the target's
/// highest observed generation is tracked; this module freezes only the
/// strictly-lower-is-rejected semantics (equal generations stay allowed so
/// drills can be repeated).
pub struct LocalRestoreRequest {
    /// Bundle directory produced by the Local export. Read-only.
    pub bundle_dir: PathBuf,
    /// Key directory to restore into.
    pub target_key_dir: PathBuf,
    /// Deployment identity of the restore target; must match the manifest.
    pub target_deployment_identity: String,
    /// Highest snapshot generation the target has already observed, when the
    /// caller tracks one.
    pub observed_generation: Option<u64>,
    /// Operator-re-supplied master key string. The master key is outside the
    /// backup domain, so it must arrive out of band; required whenever the
    /// bundle carries encrypted-at-rest records.
    pub master_key: Option<String>,
    /// Conflict policy; see [`RestoreConflictPolicy`].
    pub conflict_policy: RestoreConflictPolicy,
    /// Unix permission bits for restored files, mirroring
    /// `LocalConfig::file_permissions`.
    pub file_permissions: Option<u32>,
}

impl std::fmt::Debug for LocalRestoreRequest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LocalRestoreRequest")
            .field("bundle_dir", &self.bundle_dir)
            .field("target_key_dir", &self.target_key_dir)
            .field("target_deployment_identity", &self.target_deployment_identity)
            .field("observed_generation", &self.observed_generation)
            .field("master_key", &self.master_key.as_ref().map(|_| "<redacted>"))
            .field("conflict_policy", &self.conflict_policy)
            .field("file_permissions", &self.file_permissions)
            .finish()
    }
}

impl LocalRestoreRequest {
    fn validate(&self) -> Result<()> {
        if self.target_deployment_identity.is_empty() {
            return Err(KmsError::validation_error("restore target_deployment_identity must not be empty"));
        }
        Ok(())
    }
}

/// Serializable outcome of a completed restore. Identifiers only; never key
/// material or secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalRestoreReport {
    /// Identifier of the restored bundle.
    pub backup_id: String,
    /// Snapshot generation the target now holds.
    pub snapshot_generation: u64,
    /// Stable key ids restored into the target.
    pub restored_key_ids: Vec<String>,
    /// Whether the master-key KDF salt was restored.
    pub salt_restored: bool,
    /// Whether this run rolled forward a previously interrupted cutover.
    pub resumed: bool,
}

/// The cutover commit marker: its durable publication is the single commit
/// point of a restore. It names the exact bundle and the exact top-level
/// files the cutover publishes, so re-entry can verify it is completing the
/// same restore and an abort knows precisely what to take back.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RestoreCommitMarker {
    format_version: u32,
    backup_id: String,
    manifest_digest: ContentDigest,
    /// Final top-level file names in link order (salt first, then keys).
    files: Vec<String>,
}

impl RestoreCommitMarker {
    fn for_bundle(decoded: &DecodedBundle) -> Self {
        let mut files = Vec::with_capacity(decoded.records.len() + 1);
        if decoded.salt.is_some() {
            files.push(LOCAL_KMS_MASTER_KEY_SALT_FILE.to_string());
        }
        files.extend(decoded.records.iter().map(|record| record.file_name.clone()));
        Self {
            format_version: RESTORE_MARKER_FORMAT_VERSION,
            backup_id: decoded.manifest.backup_id.clone(),
            manifest_digest: decoded.manifest.manifest_digest.clone(),
            files,
        }
    }

    /// Strict decode: an unreadable or out-of-contract marker fails closed —
    /// abort and roll-forward both refuse to guess what a foreign or damaged
    /// marker was committing.
    fn decode(bytes: &[u8]) -> Result<Self> {
        let marker: Self = serde_json::from_slice(bytes)
            .map_err(|error| BackupError::corrupted(format!("restore commit marker does not decode: {error}")))?;
        if marker.format_version != RESTORE_MARKER_FORMAT_VERSION {
            return Err(BackupError::corrupted(format!(
                "restore commit marker has unknown format version {}",
                marker.format_version
            ))
            .into());
        }
        if marker.files.is_empty() {
            return Err(BackupError::corrupted("restore commit marker lists no files").into());
        }
        for name in &marker.files {
            if name == LOCAL_KMS_MASTER_KEY_SALT_FILE {
                continue;
            }
            match name.strip_suffix(".key") {
                Some(stem) if validate_key_id(stem).is_ok() => {}
                _ => {
                    return Err(BackupError::corrupted(format!(
                        "restore commit marker lists a file name outside the restore contract: {name:?}"
                    ))
                    .into());
                }
            }
        }
        Ok(marker)
    }

    fn matches_bundle(&self, manifest: &BackupManifest) -> bool {
        self.backup_id == manifest.backup_id && self.manifest_digest == manifest.manifest_digest
    }
}

/// One key record decoded out of the bundle, fully re-verified.
struct DecodedRecord {
    key_id: String,
    /// Final on-disk file name (`<key_id>.key`).
    file_name: String,
    protection: StoredKeyProtection,
    /// Whether the record's material is AEAD-protected under the master key
    /// (explicit marker, or a legacy record with a nonce).
    effectively_encrypted: bool,
    /// Base64-decoded `encrypted_key_material` field.
    material: Zeroizing<Vec<u8>>,
    nonce: Vec<u8>,
    /// Raw record bytes exactly as exported; what staging writes.
    plaintext: Zeroizing<Vec<u8>>,
}

/// The fully decoded and cross-checked bundle content.
struct DecodedBundle {
    manifest: BackupManifest,
    records: Vec<DecodedRecord>,
    salt: Option<Zeroizing<Vec<u8>>>,
}

impl DecodedBundle {
    fn any_record_encrypted(&self) -> bool {
        self.records.iter().any(|record| record.effectively_encrypted)
    }
}

/// Minimal projection of a stored key record, mirroring the export-side
/// probe. Unknown fields are ignored on purpose: the record is restored
/// byte-identical, so restore must not constrain its schema.
#[derive(Deserialize)]
struct RestoredRecordProbe {
    key_id: String,
    #[serde(default)]
    at_rest_protection: StoredKeyProtection,
    encrypted_key_material: String,
    #[serde(default)]
    nonce: Vec<u8>,
}

/// Zero-write restore preflight: evaluate the bundle against the target and
/// report every blocker, conflict, and external dependency mismatch.
///
/// The bundle is decoded fully in memory (including AEAD verification of
/// every artifact); the target directory is only read. Environment failures
/// (unreadable directories) surface as errors, verdicts about the bundle or
/// the target surface inside the report.
pub async fn dry_run_local_restore(kek: &BackupKek, request: &LocalRestoreRequest) -> Result<RestoreDryRunReport> {
    request.validate()?;
    let mut report = RestoreDryRunReport {
        backup_id: String::new(),
        target_deployment_identity: request.target_deployment_identity.clone(),
        blockers: Vec::new(),
        conflicts: Vec::new(),
        external_mismatches: Vec::new(),
    };

    let decoded = match decode_bundle(&request.bundle_dir, kek).await {
        Ok(decoded) => decoded,
        Err(error) => match blocker_for(&error) {
            Some(blocker) => {
                report.blockers.push(blocker);
                return Ok(report);
            }
            None => return Err(error),
        },
    };
    report.backup_id = decoded.manifest.backup_id.clone();

    if let Some(detail) = kdf_drift_detail(&decoded.manifest) {
        report.blockers.push(RestoreBlocker {
            code: RestoreBlockerCode::UnsupportedBackend,
            detail,
        });
    }
    if decoded.manifest.deployment_identity != request.target_deployment_identity {
        report.blockers.push(RestoreBlocker {
            code: RestoreBlockerCode::DeploymentMismatch,
            detail: format!(
                "bundle was produced by deployment '{}', restore target is '{}'",
                decoded.manifest.deployment_identity, request.target_deployment_identity
            ),
        });
    }
    if let Some(observed) = request.observed_generation
        && decoded.manifest.snapshot_generation < observed
    {
        report.conflicts.push(RestoreConflict {
            key_id: WHOLE_TARGET_CONFLICT_ID.to_string(),
            kind: RestoreConflictKind::GenerationRegression,
            detail: format!(
                "target observed snapshot generation {observed}, bundle carries {}; equal generations stay allowed for repeated drills",
                decoded.manifest.snapshot_generation
            ),
        });
    }

    match check_master_key(&decoded, request.master_key.as_deref())? {
        MasterKeyCheck::Ok => {}
        MasterKeyCheck::MissingPassphrase(detail) => report.blockers.push(RestoreBlocker {
            code: RestoreBlockerCode::ExternalDependencyUnavailable,
            detail,
        }),
        MasterKeyCheck::UnknownVerifierScheme(detail) => report.blockers.push(RestoreBlocker {
            code: RestoreBlockerCode::BundleCorrupted,
            detail,
        }),
        MasterKeyCheck::Mismatch { expected, observed } => report.external_mismatches.push(ExternalDependencyMismatch {
            dependency: "local KMS master key (operator-supplied)".to_string(),
            expected,
            observed,
        }),
    }

    report
        .conflicts
        .extend(enumerate_target_conflicts(&request.target_key_dir, &decoded.manifest).await?);
    Ok(report)
}

/// Restore a sealed Local bundle into the target key directory.
///
/// Fail-fast counterpart of the dry-run: the first violated precondition
/// aborts with its typed error before the target top level is touched. Only
/// [`RestoreConflictPolicy::RestoreIntoEmptyTarget`] ever writes.
pub async fn restore_local_backup(kek: &BackupKek, request: &LocalRestoreRequest) -> Result<LocalRestoreReport> {
    request.validate()?;
    if request.conflict_policy == RestoreConflictPolicy::Fail {
        return Err(KmsError::invalid_operation(
            "restore conflict policy 'fail' never writes to the target; review the dry-run report and \
             re-run with the 'restore-into-empty-target' policy to proceed",
        ));
    }

    let decoded = decode_bundle(&request.bundle_dir, kek).await?;
    if let Some(detail) = kdf_drift_detail(&decoded.manifest) {
        return Err(KmsError::unsupported_capability("local-restore", detail));
    }
    if decoded.manifest.deployment_identity != request.target_deployment_identity {
        return Err(KmsError::invalid_operation(format!(
            "bundle was produced by deployment '{}' but the restore target is '{}'",
            decoded.manifest.deployment_identity, request.target_deployment_identity
        )));
    }
    if let Some(observed) = request.observed_generation
        && decoded.manifest.snapshot_generation < observed
    {
        return Err(KmsError::invalid_operation(format!(
            "restore would regress the snapshot generation: target observed {observed}, bundle carries {}",
            decoded.manifest.snapshot_generation
        )));
    }
    match check_master_key(&decoded, request.master_key.as_deref())? {
        MasterKeyCheck::Ok => {}
        MasterKeyCheck::MissingPassphrase(detail) => return Err(KmsError::invalid_operation(detail)),
        MasterKeyCheck::UnknownVerifierScheme(detail) => return Err(BackupError::corrupted(detail).into()),
        MasterKeyCheck::Mismatch { .. } => {
            return Err(KmsError::validation_error(
                "operator-supplied master key does not match the bundle's master-key verifier",
            ));
        }
    }

    // Decryption-probe every record in memory before anything is written: a
    // wrong master key on a verifier-less legacy bundle must fail here, with
    // the target untouched.
    let ciphers = derive_ciphers(request.master_key.as_deref(), decoded.salt.as_deref().map(Vec::as_slice))?;
    for record in &decoded.records {
        probe_record_decryption(record, &ciphers)?;
    }

    let mode = inspect_target_for_restore(&request.target_key_dir, &decoded.manifest).await?;
    let expected_marker = RestoreCommitMarker::for_bundle(&decoded);
    let resumed = match &mode {
        TargetMode::Fresh { .. } => false,
        TargetMode::RollForward(published) => {
            if *published != expected_marker {
                return Err(BackupError::corrupted(
                    "published restore marker names the same bundle but a different file set; \
                     abort the interrupted restore before retrying",
                )
                .into());
            }
            true
        }
    };

    // Phase B: staging.
    let staging = request.target_key_dir.join(RESTORE_STAGING_DIR);
    if let TargetMode::Fresh { stale_entries } = &mode {
        // A fresh run owns no prior state: drop staging leftovers of an
        // earlier interrupted attempt and any orphan commit temps, then start
        // over from the bundle.
        if fs::try_exists(&staging).await? {
            remove_dir_all_durably(&request.target_key_dir, &staging).await?;
        }
        for stale in stale_entries {
            durable_file::remove_durably(request.target_key_dir.join(stale)).await?;
        }
    }
    fs::create_dir_all(&staging).await?;
    if let Some(salt) = &decoded.salt {
        stage_file(&staging, LOCAL_KMS_MASTER_KEY_SALT_FILE, salt, request.file_permissions).await?;
    }
    for record in &decoded.records {
        stage_file(&staging, &record.file_name, &record.plaintext, request.file_permissions).await?;
    }
    // Make the staging directory entry itself durable before the commit
    // point: the marker must never survive a crash that its staged files did
    // not.
    fsync_dir(&request.target_key_dir).await?;
    probe_staged_bundle(&staging, &decoded, &ciphers).await?;

    // Phase C: commit point, then cutover.
    if !resumed {
        publish_marker(&request.target_key_dir, &expected_marker, request.file_permissions).await?;
    }
    cutover_and_finish(&request.target_key_dir, &staging, &expected_marker).await?;

    Ok(LocalRestoreReport {
        backup_id: decoded.manifest.backup_id.clone(),
        snapshot_generation: decoded.manifest.snapshot_generation,
        restored_key_ids: decoded.records.iter().map(|record| record.key_id.clone()).collect(),
        salt_restored: decoded.salt.is_some(),
        resumed,
    })
}

/// Explicitly abort an interrupted restore, returning the target to its
/// pre-restore (empty) state.
///
/// With a published marker this takes back exactly the files the marker
/// names, then the marker, then the staging directory. Without a marker only
/// staging leftovers exist and are removed. Fails closed on a marker it
/// cannot strictly decode.
pub async fn abort_local_restore(target_key_dir: &Path) -> Result<()> {
    let marker_path = target_key_dir.join(LOCAL_RESTORE_COMMIT_MARKER_FILE);
    let staging = target_key_dir.join(RESTORE_STAGING_DIR);
    match fs::read(&marker_path).await {
        Ok(bytes) => {
            let marker = RestoreCommitMarker::decode(&bytes)?;
            for file_name in &marker.files {
                let path = target_key_dir.join(file_name);
                if fs::try_exists(&path).await? {
                    durable_file::remove_durably(path).await?;
                }
            }
            durable_file::remove_durably(marker_path).await?;
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            if !fs::try_exists(&staging).await? {
                return Err(KmsError::invalid_operation(format!(
                    "no restore is in progress under {}",
                    target_key_dir.display()
                )));
            }
        }
        Err(error) => return Err(error.into()),
    }
    if fs::try_exists(&staging).await? {
        remove_dir_all_durably(target_key_dir, &staging).await?;
    }
    Ok(())
}

/// Decode and cross-verify the complete bundle in memory.
///
/// Every artifact is read, digest-verified, and AEAD-authenticated; every key
/// record is re-parsed, identity-checked against its artifact path, and
/// checked for protection consistency with the manifest's KDF descriptor.
async fn decode_bundle(bundle_dir: &Path, kek: &BackupKek) -> Result<DecodedBundle> {
    let manifest = read_local_bundle_manifest(bundle_dir).await?;
    let descriptor = manifest
        .local_kdf
        .clone()
        .ok_or_else(|| BackupError::corrupted("local bundle manifest carries no KDF descriptor"))?;

    let mut records = Vec::new();
    let mut salt: Option<Zeroizing<Vec<u8>>> = None;
    let mut config_seen = false;
    for artifact in &manifest.artifacts {
        match artifact.kind {
            ArtifactKind::KeyMaterial => {
                let plaintext = decrypt_bundle_artifact(bundle_dir, &manifest, artifact, kek).await?;
                records.push(decode_key_record(artifact.path.as_str(), plaintext, &descriptor.protection_modes)?);
            }
            ArtifactKind::MasterKeySalt => {
                if salt.is_some() {
                    return Err(BackupError::corrupted("bundle carries more than one master-key salt artifact").into());
                }
                let plaintext = decrypt_bundle_artifact(bundle_dir, &manifest, artifact, kek).await?;
                if plaintext.len() != LOCAL_KMS_MASTER_KEY_SALT_LEN {
                    return Err(BackupError::corrupted(format!(
                        "bundled master-key salt is {} bytes, expected {}",
                        plaintext.len(),
                        LOCAL_KMS_MASTER_KEY_SALT_LEN
                    ))
                    .into());
                }
                salt = Some(plaintext);
            }
            // Configuration is evidence, not restorable state: the artifact is
            // verified so a tampered bundle still fails closed, but restore
            // never writes a configuration into the target. Presenting the
            // difference against the running configuration, and any decision to
            // adopt it, belongs to the admin layer.
            ArtifactKind::KmsConfig => {
                if config_seen {
                    return Err(BackupError::corrupted("bundle carries more than one KMS configuration artifact").into());
                }
                config_seen = true;
                decrypt_bundle_artifact(bundle_dir, &manifest, artifact, kek).await?;
            }
            // No producer emits these for a Local bundle yet; restoring a
            // bundle that carries state this implementation cannot apply
            // would silently drop it, so fail closed instead.
            other => {
                return Err(KmsError::unsupported_capability(
                    "local-restore",
                    format!("bundle carries artifact kind {other:?}, which this restore implementation cannot apply"),
                ));
            }
        }
    }

    // The salt must be exactly as coherent with the KDF descriptor as the
    // export invariants promise; anything else is a broken or tampered
    // bundle.
    match (&descriptor.derivation, salt.is_some()) {
        (LocalKeyDerivation::Argon2id { .. }, false) => {
            return Err(
                BackupError::corrupted("KDF descriptor declares Argon2id but the bundle carries no salt artifact").into(),
            );
        }
        (LocalKeyDerivation::LegacySha256, true) => {
            return Err(
                BackupError::corrupted("KDF descriptor declares the legacy derivation but the bundle carries a salt").into(),
            );
        }
        _ => {}
    }

    Ok(DecodedBundle { manifest, records, salt })
}

fn decode_key_record(
    artifact_path: &str,
    plaintext: Zeroizing<Vec<u8>>,
    allowed_modes: &[AtRestProtection],
) -> Result<DecodedRecord> {
    let stem = Path::new(artifact_path)
        .file_name()
        .and_then(|name| name.to_str())
        .and_then(|name| name.strip_suffix(".key.enc"))
        .ok_or_else(|| {
            BackupError::corrupted(format!(
                "key material artifact path {artifact_path:?} does not follow the '<key_id>.key.enc' layout"
            ))
        })?
        .to_string();
    validate_key_id(&stem)?;

    // Classify the protection marker before the schema parse: a record from a
    // newer build is not a damaged bundle, and reporting it as corruption
    // sends the operator into disaster recovery instead of a version change.
    let format_version = stored_master_key_format_version(&plaintext)
        .map_err(|error| BackupError::corrupted(format!("bundled key record '{stem}' is not a readable JSON object: {error}")))?;
    if format_version > STORED_MASTER_KEY_FORMAT_VERSION {
        return Err(BackupError::UnsupportedFormatVersion {
            key_id: stem,
            version: format_version.to_string(),
        }
        .into());
    }
    let has_unknown_marker = has_unknown_protection_marker(&plaintext)
        .map_err(|error| BackupError::corrupted(format!("bundled key record '{stem}' is not a readable JSON object: {error}")))?;
    if has_unknown_marker {
        return Err(BackupError::UnsupportedRecordVersion {
            key_id: stem,
            version: UNKNOWN_STORED_KEY_PROTECTION.to_owned(),
        }
        .into());
    }
    let probe: RestoredRecordProbe = serde_json::from_slice(&plaintext)
        .map_err(|error| BackupError::corrupted(format!("bundled key record '{stem}' does not deserialize: {error}")))?;
    if probe.key_id != stem {
        return Err(BackupError::corrupted(format!(
            "bundled key record identity mismatch: artifact names {stem:?}, record names {:?}",
            probe.key_id
        ))
        .into());
    }
    if probe.encrypted_key_material.is_empty() {
        return Err(BackupError::corrupted(format!("bundled key record '{stem}' carries no key material")).into());
    }
    let material =
        Zeroizing::new(BASE64.decode_to_vec(&probe.encrypted_key_material).map_err(|error| {
            BackupError::corrupted(format!("bundled key record '{stem}' material is not valid base64: {error}"))
        })?);
    if !allowed_modes.contains(&protection_mode(probe.at_rest_protection)) {
        return Err(BackupError::corrupted(format!(
            "bundled key record '{stem}' declares protection {:?}, absent from the manifest's KDF descriptor",
            probe.at_rest_protection
        ))
        .into());
    }

    let effectively_encrypted = match probe.at_rest_protection {
        StoredKeyProtection::EncryptedMasterKey => true,
        StoredKeyProtection::PlaintextDevOnly => false,
        StoredKeyProtection::LegacyUnspecified => !probe.nonce.is_empty(),
    };
    if effectively_encrypted && probe.nonce.len() != 12 {
        return Err(BackupError::corrupted(format!(
            "bundled key record '{stem}' has an invalid nonce length ({} bytes, expected 12)",
            probe.nonce.len()
        ))
        .into());
    }

    Ok(DecodedRecord {
        file_name: format!("{stem}.key"),
        key_id: stem,
        protection: probe.at_rest_protection,
        effectively_encrypted,
        material,
        nonce: probe.nonce,
        plaintext,
    })
}

fn protection_mode(protection: StoredKeyProtection) -> AtRestProtection {
    match protection {
        StoredKeyProtection::EncryptedMasterKey => AtRestProtection::EncryptedMasterKey,
        StoredKeyProtection::PlaintextDevOnly => AtRestProtection::PlaintextDevOnly,
        StoredKeyProtection::LegacyUnspecified => AtRestProtection::LegacyUnspecified,
    }
}

/// Detail message when the bundle's recorded KDF differs from what this build
/// compiles in; restoring such a bundle would produce keys this build cannot
/// decrypt.
fn kdf_drift_detail(manifest: &BackupManifest) -> Option<String> {
    match &manifest.local_kdf.as_ref()?.derivation {
        derivation @ LocalKeyDerivation::Argon2id { .. } => {
            let current = LocalKeyDerivation::current_argon2id();
            (*derivation != current).then(|| {
                format!("bundle KDF descriptor {derivation:?} does not match this build's compiled-in derivation {current:?}")
            })
        }
        LocalKeyDerivation::LegacySha256 => None,
    }
}

enum MasterKeyCheck {
    Ok,
    MissingPassphrase(String),
    UnknownVerifierScheme(String),
    Mismatch { expected: String, observed: String },
}

/// Verify the operator-supplied master key against the manifest's one-way
/// verifier, and require one whenever the bundle carries encrypted records.
///
/// Bundles without a verifier (produced before the verifier landed) fall
/// back to the staging-phase decryption probe; unknown verifier schemes fail
/// closed.
fn check_master_key(decoded: &DecodedBundle, master_key: Option<&str>) -> Result<MasterKeyCheck> {
    let Some(master_key) = master_key else {
        if decoded.any_record_encrypted() {
            return Ok(MasterKeyCheck::MissingPassphrase(
                "bundle carries encrypted-at-rest key records; the operator must re-supply the master key before restore"
                    .to_string(),
            ));
        }
        return Ok(MasterKeyCheck::Ok);
    };

    let verifier = decoded
        .manifest
        .local_kdf
        .as_ref()
        .and_then(|descriptor| descriptor.master_key_verifier.as_deref());
    let Some(verifier) = verifier else {
        return Ok(MasterKeyCheck::Ok);
    };

    let observed = if verifier.starts_with(MASTER_KEY_VERIFIER_ARGON2ID_PREFIX) {
        let Some(salt) = decoded.salt.as_deref() else {
            return Err(
                BackupError::corrupted("manifest carries an Argon2id master-key verifier but the bundle has no salt").into(),
            );
        };
        compute_master_key_verifier(master_key, Some(salt), &decoded.manifest.backup_id)?
    } else if verifier.starts_with(MASTER_KEY_VERIFIER_LEGACY_PREFIX) {
        compute_master_key_verifier(master_key, None, &decoded.manifest.backup_id)?
    } else {
        return Ok(MasterKeyCheck::UnknownVerifierScheme(
            "manifest carries a master-key verifier with an unknown scheme prefix; failing closed".to_string(),
        ));
    };

    if observed != verifier {
        return Ok(MasterKeyCheck::Mismatch {
            expected: verifier.to_string(),
            observed,
        });
    }
    Ok(MasterKeyCheck::Ok)
}

/// AEAD ciphers derived from the operator-supplied master key, mirroring the
/// backend's current-plus-legacy pair.
struct DerivedCiphers {
    current: Option<Aes256Gcm>,
    legacy: Option<Aes256Gcm>,
}

fn derive_ciphers(master_key: Option<&str>, salt: Option<&[u8]>) -> Result<DerivedCiphers> {
    let Some(master_key) = master_key else {
        return Ok(DerivedCiphers {
            current: None,
            legacy: None,
        });
    };
    let legacy = Aes256Gcm::new(&LocalKmsClient::derive_legacy_master_key(master_key)?);
    let current = match salt {
        Some(salt) => Aes256Gcm::new(&LocalKmsClient::derive_master_key(master_key, salt)?),
        None => legacy.clone(),
    };
    Ok(DerivedCiphers {
        current: Some(current),
        legacy: Some(legacy),
    })
}

/// Decryption probe for one record, mirroring the backend's read path: the
/// current cipher first, with the legacy fallback only for legacy-unmarked
/// records. Local has no rotation history, so probing every record is the
/// full-coverage probe.
fn probe_record_decryption(record: &DecodedRecord, ciphers: &DerivedCiphers) -> Result<()> {
    if !record.effectively_encrypted {
        return Ok(());
    }
    let Some(current) = ciphers.current.as_ref() else {
        return Err(KmsError::invalid_operation(format!(
            "bundled key record '{}' is encrypted at rest and requires the operator-supplied master key",
            record.key_id
        )));
    };
    let mut nonce = [0u8; 12];
    nonce.copy_from_slice(&record.nonce);
    let nonce = Nonce::from(nonce);
    match current.decrypt(&nonce, record.material.as_slice()) {
        Ok(material) => {
            drop(Zeroizing::new(material));
            Ok(())
        }
        Err(_) if record.protection == StoredKeyProtection::LegacyUnspecified => {
            let legacy = ciphers
                .legacy
                .as_ref()
                .expect("legacy cipher exists whenever the current cipher does");
            let material = legacy
                .decrypt(&nonce, record.material.as_slice())
                .map_err(|_| KmsError::material_authentication_failed(&record.key_id))?;
            drop(Zeroizing::new(material));
            Ok(())
        }
        Err(_) => Err(KmsError::material_authentication_failed(&record.key_id)),
    }
}

/// Re-run the decryption probe against the bytes that actually landed in
/// staging, so cutover only ever publishes state proven readable from disk.
async fn probe_staged_bundle(staging: &Path, decoded: &DecodedBundle, ciphers: &DerivedCiphers) -> Result<()> {
    if let Some(salt) = &decoded.salt {
        let staged_salt = fs::read(staging.join(LOCAL_KMS_MASTER_KEY_SALT_FILE)).await?;
        if staged_salt.as_slice() != salt.as_slice() {
            return Err(BackupError::corrupted("staged master-key salt does not match the bundle").into());
        }
    }
    for record in &decoded.records {
        let staged = Zeroizing::new(fs::read(staging.join(&record.file_name)).await?);
        if staged.as_slice() != record.plaintext.as_slice() {
            return Err(
                BackupError::corrupted(format!("staged key record '{}' does not match the bundle", record.key_id)).into(),
            );
        }
        let staged_record = decode_key_record(
            &format!("staged/{}.enc", record.file_name),
            staged,
            &decoded
                .manifest
                .local_kdf
                .as_ref()
                .expect("decoded bundle always carries a KDF descriptor")
                .protection_modes,
        )?;
        probe_record_decryption(&staged_record, ciphers)?;
    }
    Ok(())
}

enum TargetMode {
    /// The target is empty apart from removable leftovers of an interrupted
    /// earlier attempt (staging, orphan commit temps).
    Fresh { stale_entries: Vec<String> },
    /// A published marker of this same bundle: complete its cutover.
    RollForward(RestoreCommitMarker),
}

/// Fail-fast target inspection for an actual restore.
async fn inspect_target_for_restore(target: &Path, manifest: &BackupManifest) -> Result<TargetMode> {
    if !fs::try_exists(target).await? {
        return Ok(TargetMode::Fresh {
            stale_entries: Vec::new(),
        });
    }

    let marker = read_marker(target).await?;
    if let Some(marker) = marker {
        if !marker.matches_bundle(manifest) {
            return Err(KmsError::invalid_operation(format!(
                "target key directory holds a restore marker for a different bundle ('{}'); \
                 roll that restore forward with its own bundle or abort it explicitly",
                marker.backup_id
            )));
        }
        // Everything at the top level must belong to this restore: the
        // marker's files (partially linked) and our own working entries.
        let allowed: Vec<&str> = marker.files.iter().map(String::as_str).collect();
        for entry in list_target_entries(target).await? {
            if !allowed.contains(&entry.as_str()) && !is_orphan_commit_temp_name(&entry) {
                return Err(KmsError::invalid_operation(format!(
                    "target key directory holds an entry outside the interrupted restore: {entry:?}; \
                     clean it up manually before resuming"
                )));
            }
        }
        return Ok(TargetMode::RollForward(marker));
    }

    let mut stale_entries = Vec::new();
    for entry in list_target_entries(target).await? {
        if is_orphan_commit_temp_name(&entry) {
            // Unpublished commit temps are never authoritative; a re-run may
            // remove them exactly as startup recovery would.
            stale_entries.push(entry);
            continue;
        }
        let detail = if entry == LOCAL_KMS_MASTER_KEY_SALT_FILE {
            "an orphan KDF salt would poison every key created after the restore; clean it up manually"
        } else {
            "restore only ever writes into an empty target"
        };
        return Err(KmsError::invalid_operation(format!(
            "target key directory is not empty (found {entry:?}); {detail}"
        )));
    }
    Ok(TargetMode::Fresh { stale_entries })
}

/// Top-level entries of the target, excluding the staging directory and the
/// commit marker (both handled separately).
async fn list_target_entries(target: &Path) -> Result<Vec<String>> {
    let mut names = Vec::new();
    let mut entries = fs::read_dir(target).await?;
    while let Some(entry) = entries.next_entry().await? {
        let name = entry
            .file_name()
            .to_str()
            .ok_or_else(|| KmsError::invalid_operation("target key directory holds a non-UTF-8 entry; clean it up manually"))?
            .to_string();
        if name == RESTORE_STAGING_DIR || name == LOCAL_RESTORE_COMMIT_MARKER_FILE {
            continue;
        }
        names.push(name);
    }
    names.sort();
    Ok(names)
}

async fn read_marker(target: &Path) -> Result<Option<RestoreCommitMarker>> {
    match fs::read(target.join(LOCAL_RESTORE_COMMIT_MARKER_FILE)).await {
        Ok(bytes) => Ok(Some(RestoreCommitMarker::decode(&bytes)?)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(error.into()),
    }
}

/// Conflict enumeration for the dry-run: every target entry that would block
/// the restore, without failing on the first one.
async fn enumerate_target_conflicts(target: &Path, manifest: &BackupManifest) -> Result<Vec<RestoreConflict>> {
    if !fs::try_exists(target).await? {
        return Ok(Vec::new());
    }
    let mut conflicts = Vec::new();

    let marker = match read_marker(target).await {
        Ok(marker) => marker,
        // A marker that does not strictly decode blocks exactly like a
        // foreign one; the dry-run reports it instead of failing.
        Err(error) => {
            conflicts.push(RestoreConflict {
                key_id: LOCAL_RESTORE_COMMIT_MARKER_FILE.to_string(),
                kind: RestoreConflictKind::KeyAlreadyExists,
                detail: format!("target holds an undecodable restore marker: {error}"),
            });
            None
        }
    };
    let own_files: Vec<String> = match &marker {
        Some(marker) if marker.matches_bundle(manifest) => marker.files.clone(),
        Some(marker) => {
            conflicts.push(RestoreConflict {
                key_id: LOCAL_RESTORE_COMMIT_MARKER_FILE.to_string(),
                kind: RestoreConflictKind::KeyAlreadyExists,
                detail: format!("target holds a restore marker for a different bundle ('{}')", marker.backup_id),
            });
            Vec::new()
        }
        None => Vec::new(),
    };

    for entry in list_target_entries(target).await? {
        if own_files.contains(&entry) || is_orphan_commit_temp_name(&entry) {
            continue;
        }
        let (key_id, detail) = if let Some(stem) = entry.strip_suffix(".key") {
            (stem.to_string(), format!("target already has key file {entry:?}"))
        } else if entry == LOCAL_KMS_MASTER_KEY_SALT_FILE {
            (
                entry.clone(),
                "target holds an orphan master-key KDF salt; it would poison every key created after the restore".to_string(),
            )
        } else {
            (entry.clone(), format!("target holds an unexpected entry {entry:?}"))
        };
        conflicts.push(RestoreConflict {
            key_id,
            kind: RestoreConflictKind::KeyAlreadyExists,
            detail,
        });
    }
    Ok(conflicts)
}

/// Durably commit one staged file. Idempotent across roll-forward re-entry:
/// an already staged file is accepted only byte-identical.
async fn stage_file(staging: &Path, file_name: &str, content: &[u8], permissions: Option<u32>) -> Result<()> {
    let final_path = staging.join(file_name);
    match fs::read(&final_path).await {
        Ok(existing) => {
            let existing = Zeroizing::new(existing);
            if existing.as_slice() == content {
                return Ok(());
            }
            return Err(BackupError::corrupted(format!(
                "staging already holds '{file_name}' with different content; abort the interrupted restore before retrying"
            ))
            .into());
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
        Err(error) => return Err(error.into()),
    }
    let temp_path = staging.join(format!("{file_name}.tmp-{}", uuid::Uuid::new_v4()));
    durable_file::commit(temp_path, final_path, content.to_vec(), permissions, durable_file::Publish::NoClobber)
        .await
        .map_err(KmsError::from)
}

/// Durably publish the commit marker. This is the restore's single commit
/// point: before it, the target top level is untouched; after it, the only
/// ways out are roll-forward or explicit abort.
async fn publish_marker(target: &Path, marker: &RestoreCommitMarker, permissions: Option<u32>) -> Result<()> {
    let content = serde_json::to_vec_pretty(marker)
        .map_err(|error| KmsError::internal_error(format!("restore commit marker serialization failed: {error}")))?;
    let final_path = target.join(LOCAL_RESTORE_COMMIT_MARKER_FILE);
    let temp_path = target.join(format!("{LOCAL_RESTORE_COMMIT_MARKER_FILE}.tmp-{}", uuid::Uuid::new_v4()));
    durable_file::commit(temp_path, final_path, content, permissions, durable_file::Publish::NoClobber)
        .await
        .map_err(KmsError::from)
}

/// The atomic cutover: publish every staged file under its final name (salt
/// first, keys after — the marker froze that order), durably remove the
/// marker, then drop the staging directory.
async fn cutover_and_finish(target: &Path, staging: &Path, marker: &RestoreCommitMarker) -> Result<()> {
    for file_name in &marker.files {
        durable_file::link_durably(staging.join(file_name), target.join(file_name))
            .await
            .map_err(|error| KmsError::io_error(format!("restore cutover failed publishing '{file_name}': {error}")))?;
    }
    durable_file::remove_durably(target.join(LOCAL_RESTORE_COMMIT_MARKER_FILE))
        .await
        .map_err(|error| KmsError::io_error(format!("restore cutover failed removing the commit marker: {error}")))?;
    // The restore is complete once the marker is gone; a leftover staging
    // directory is inert (its entries are hard links of the published files),
    // so its removal is cleanup, not protocol.
    if let Err(error) = remove_dir_all_durably(target, staging).await {
        warn!(%error, "restored Local KMS key directory kept an inert staging directory");
    }
    Ok(())
}

async fn remove_dir_all_durably(parent: &Path, dir: &Path) -> Result<()> {
    let dir = dir.to_path_buf();
    tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir))
        .await
        .map_err(|error| KmsError::io_error(error.to_string()))??;
    fsync_dir(parent).await
}

fn blocker_for(error: &KmsError) -> Option<RestoreBlocker> {
    match error {
        KmsError::Backup(inner) => Some(RestoreBlocker::from(inner)),
        KmsError::UnsupportedCapability { .. } => Some(RestoreBlocker {
            code: RestoreBlockerCode::UnsupportedBackend,
            detail: error.to_string(),
        }),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backup::local_export::{LOCAL_BUNDLE_MANIFEST_FILE, LocalBackupExportRequest, export_local_backup};
    use crate::config::LocalConfig;
    use sha2::{Digest, Sha256};
    use std::time::SystemTime;
    use tempfile::TempDir;

    const TEST_MASTER_KEY: &str = "test-master-key";
    const DEPLOYMENT: &str = "deployment-test";
    const BACKUP_ID: &str = "backup-0001";
    const SNAPSHOT_GENERATION: u64 = 7;

    fn local_config(dir: &Path, master_key: Option<&str>) -> LocalConfig {
        LocalConfig {
            key_dir: dir.to_path_buf(),
            master_key: master_key.map(str::to_string),
            file_permissions: Some(0o600),
        }
    }

    async fn source_with_keys(master_key: Option<&str>, keys: &[&str]) -> (LocalKmsClient, TempDir) {
        let temp = TempDir::new().expect("temp dir");
        let client = LocalKmsClient::new(local_config(temp.path(), master_key))
            .await
            .expect("client should initialize");
        for key in keys {
            client.create_key(key, "AES_256", None).await.expect("create key");
        }
        (client, temp)
    }

    fn test_kek() -> BackupKek {
        BackupKek::new("backup-kek-test", 1, [0x42; 32]).expect("kek")
    }

    async fn export_bundle(client: &LocalKmsClient, dir: &Path) -> (PathBuf, BackupManifest) {
        export_bundle_with_config(client, dir, None).await
    }

    async fn export_bundle_with_config(
        client: &LocalKmsClient,
        dir: &Path,
        sanitized_config: Option<Vec<u8>>,
    ) -> (PathBuf, BackupManifest) {
        let destination = dir.join("bundle");
        let manifest = export_local_backup(
            client,
            &test_kek(),
            &LocalBackupExportRequest {
                backup_id: BACKUP_ID.to_string(),
                deployment_identity: DEPLOYMENT.to_string(),
                rustfs_version: "1.0.0-test".to_string(),
                snapshot_generation: SNAPSHOT_GENERATION,
                destination: destination.clone(),
                sanitized_config,
            },
        )
        .await
        .expect("export should succeed");
        (destination, manifest)
    }

    fn restore_request(bundle: &Path, target: &Path) -> LocalRestoreRequest {
        LocalRestoreRequest {
            bundle_dir: bundle.to_path_buf(),
            target_key_dir: target.to_path_buf(),
            target_deployment_identity: DEPLOYMENT.to_string(),
            observed_generation: None,
            master_key: Some(TEST_MASTER_KEY.to_string()),
            conflict_policy: RestoreConflictPolicy::RestoreIntoEmptyTarget,
            file_permissions: Some(0o600),
        }
    }

    /// Recursive (path, content SHA-256, mtime) snapshot; equality across an
    /// operation proves it performed no persistent write at all.
    fn snapshot_tree(dir: &Path) -> Vec<(String, Vec<u8>, SystemTime)> {
        fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>, SystemTime)>) {
            for entry in std::fs::read_dir(dir).expect("read dir") {
                let entry = entry.expect("dir entry");
                let path = entry.path();
                if path.is_dir() {
                    walk(root, &path, out);
                    continue;
                }
                let relative = path.strip_prefix(root).expect("under root").to_string_lossy().into_owned();
                let content = std::fs::read(&path).expect("read file");
                let mtime = entry.metadata().expect("metadata").modified().expect("mtime");
                out.push((relative, Sha256::digest(&content).to_vec(), mtime));
            }
        }
        let mut out = Vec::new();
        walk(dir, dir, &mut out);
        out.sort();
        out
    }

    async fn top_level_names(dir: &Path) -> Vec<String> {
        let mut names = Vec::new();
        let mut entries = fs::read_dir(dir).await.expect("read dir");
        while let Some(entry) = entries.next_entry().await.expect("dir entry") {
            names.push(entry.file_name().to_string_lossy().into_owned());
        }
        names.sort();
        names
    }

    /// Re-seal the bundle manifest after mutating it, keeping the digest
    /// contract intact so only the mutated aspect is under test.
    async fn reseal_manifest(bundle_dir: &Path, mutate: impl FnOnce(&mut BackupManifest)) {
        let path = bundle_dir.join(LOCAL_BUNDLE_MANIFEST_FILE);
        let mut manifest = BackupManifest::decode(&fs::read(&path).await.expect("manifest bytes")).expect("manifest decodes");
        mutate(&mut manifest);
        let sealed = manifest.seal().expect("re-seal");
        fs::write(&path, sealed.encode().expect("encode"))
            .await
            .expect("write manifest");
    }

    /// The full acceptance check for a restored directory: raw files
    /// byte-identical to the source, and every record decodable through the
    /// read-only export constructor with byte-identical material.
    async fn assert_restored_matches_source(source: &LocalKmsClient, target: &Path, keys: &[&str]) {
        for key in keys {
            let source_bytes = fs::read(source.key_directory().join(format!("{key}.key")))
                .await
                .expect("source record");
            let restored_bytes = fs::read(target.join(format!("{key}.key"))).await.expect("restored record");
            assert_eq!(source_bytes, restored_bytes, "record {key} must restore byte-for-byte");
        }
        let source_salt = fs::read(source.master_key_salt_file()).await.expect("source salt");
        let restored_salt = fs::read(target.join(LOCAL_KMS_MASTER_KEY_SALT_FILE))
            .await
            .expect("restored salt");
        assert_eq!(source_salt, restored_salt, "salt must restore byte-for-byte");

        let restored = LocalKmsClient::new_for_key_export(local_config(target, Some(TEST_MASTER_KEY)))
            .await
            .expect("restored directory must open read-only");
        for key in keys {
            let source_material = source.decrypt_key_material_for_export(key).await.expect("source material");
            let restored_material = restored
                .decrypt_key_material_for_export(key)
                .await
                .expect("restored material");
            assert_eq!(
                source_material.as_ref(),
                restored_material.as_ref(),
                "key material for {key} must survive the restore byte-for-byte"
            );
        }
    }

    #[tokio::test]
    async fn dry_run_is_zero_write_and_permits_clean_restore() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha", "beta"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");
        fs::create_dir_all(&target).await.expect("create target");

        let bundle_before = snapshot_tree(&bundle);
        let target_before = snapshot_tree(&target);
        let report = dry_run_local_restore(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("dry run should succeed");

        assert!(report.restore_permitted(), "clean bundle and empty target must permit: {report:?}");
        assert_eq!(report.backup_id, BACKUP_ID);
        assert_eq!(report.target_deployment_identity, DEPLOYMENT);
        assert_eq!(snapshot_tree(&bundle), bundle_before, "dry run must not touch the bundle");
        assert_eq!(snapshot_tree(&target), target_before, "dry run must not touch the target");

        // A missing target directory stays missing.
        let missing = work.path().join("missing-target");
        let report = dry_run_local_restore(&test_kek(), &restore_request(&bundle, &missing))
            .await
            .expect("dry run should succeed");
        assert!(report.restore_permitted());
        assert!(!missing.exists(), "dry run must not create the target directory");
    }

    #[tokio::test]
    async fn dry_run_flags_deployment_mismatch_and_generation_regression() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let mut request = restore_request(&bundle, &target);
        request.target_deployment_identity = "deployment-other".to_string();
        request.observed_generation = Some(SNAPSHOT_GENERATION + 1);
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::DeploymentMismatch),
            "expected a deployment mismatch blocker: {report:?}"
        );
        assert!(
            report
                .conflicts
                .iter()
                .any(|conflict| conflict.kind == RestoreConflictKind::GenerationRegression),
            "expected a generation regression conflict: {report:?}"
        );

        // Equal generations are allowed so drills can repeat.
        let mut request = restore_request(&bundle, &target);
        request.observed_generation = Some(SNAPSHOT_GENERATION);
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(report.restore_permitted(), "equal generation must not conflict: {report:?}");
    }

    #[tokio::test]
    async fn dry_run_checks_the_operator_master_key() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let verifier = manifest
            .local_kdf
            .as_ref()
            .and_then(|kdf| kdf.master_key_verifier.as_deref())
            .expect("encrypted bundle must carry a verifier");
        assert!(verifier.starts_with(MASTER_KEY_VERIFIER_ARGON2ID_PREFIX), "got {verifier:?}");

        // No passphrase: encrypted records make the master key a required
        // external dependency.
        let mut request = restore_request(&bundle, &target);
        request.master_key = None;
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::ExternalDependencyUnavailable),
            "expected a missing-master-key blocker: {report:?}"
        );

        // Wrong passphrase: reported as an external dependency mismatch.
        let mut request = restore_request(&bundle, &target);
        request.master_key = Some("wrong-master-key".to_string());
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert_eq!(report.external_mismatches.len(), 1, "{report:?}");
        assert_eq!(report.external_mismatches[0].expected, verifier);
        assert!(!report.restore_permitted());
    }

    #[tokio::test]
    async fn dry_run_enumerates_existing_target_state() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;

        // A populated target: a key file, the salt, and a stray file.
        let (_occupant, target_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["occupied"]).await;
        fs::write(target_dir.path().join("operator-notes.txt"), b"leftover")
            .await
            .expect("stray file");

        let report = dry_run_local_restore(&test_kek(), &restore_request(&bundle, target_dir.path()))
            .await
            .expect("dry run");
        let conflicting_ids: Vec<&str> = report.conflicts.iter().map(|conflict| conflict.key_id.as_str()).collect();
        assert!(conflicting_ids.contains(&"occupied"), "{report:?}");
        assert!(conflicting_ids.contains(&LOCAL_KMS_MASTER_KEY_SALT_FILE), "{report:?}");
        assert!(conflicting_ids.contains(&"operator-notes.txt"), "{report:?}");
        assert!(!report.restore_permitted());
    }

    #[tokio::test]
    async fn restore_into_empty_target_round_trips_byte_for_byte() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha", "beta"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let bundle_before = snapshot_tree(&bundle);
        let report = restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("restore should succeed");

        assert_eq!(
            report,
            LocalRestoreReport {
                backup_id: BACKUP_ID.to_string(),
                snapshot_generation: SNAPSHOT_GENERATION,
                restored_key_ids: vec!["alpha".to_string(), "beta".to_string()],
                salt_restored: true,
                resumed: false,
            }
        );
        assert_eq!(snapshot_tree(&bundle), bundle_before, "the bundle source must stay byte-identical");
        assert_eq!(
            top_level_names(&target).await,
            vec![
                LOCAL_KMS_MASTER_KEY_SALT_FILE.to_string(),
                "alpha.key".to_string(),
                "beta.key".to_string()
            ],
            "cutover must leave no marker, staging, or temp files behind"
        );
        assert_restored_matches_source(&client, &target, &["alpha", "beta"]).await;

        // The restored directory boots as a normal backend directory.
        let restored = LocalKmsClient::new(local_config(&target, Some(TEST_MASTER_KEY)))
            .await
            .expect("restored directory must pass startup validation");
        restored.describe_key("alpha", None).await.expect("restored key describes");
    }

    #[tokio::test]
    async fn default_fail_policy_never_writes() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let mut request = restore_request(&bundle, &target);
        request.conflict_policy = RestoreConflictPolicy::Fail;
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("the default policy must refuse to write");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");
        assert!(!target.exists(), "the fail policy must not create the target");
    }

    #[tokio::test]
    async fn non_empty_targets_are_rejected() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, manifest) = export_bundle(&client, work.path()).await;

        // Existing key material.
        let (_occupant, occupied) = source_with_keys(Some(TEST_MASTER_KEY), &["occupied"]).await;
        let before = snapshot_tree(occupied.path());
        let error = restore_local_backup(&test_kek(), &restore_request(&bundle, occupied.path()))
            .await
            .expect_err("occupied target must be rejected");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");
        assert_eq!(snapshot_tree(occupied.path()), before, "the rejected target must stay untouched");

        // An orphan salt alone makes the target non-empty: a foreign salt
        // would poison every key created after the restore.
        let orphan = TempDir::new().expect("orphan dir");
        fs::write(orphan.path().join(LOCAL_KMS_MASTER_KEY_SALT_FILE), [7u8; 16])
            .await
            .expect("seed orphan salt");
        let before = snapshot_tree(orphan.path());
        let error = restore_local_backup(&test_kek(), &restore_request(&bundle, orphan.path()))
            .await
            .expect_err("orphan salt must be rejected");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");
        assert!(error.to_string().contains("salt"), "got {error}");
        assert_eq!(snapshot_tree(orphan.path()), before);

        // A marker of a different restore.
        let foreign = TempDir::new().expect("foreign dir");
        let mut digest = manifest.manifest_digest.clone();
        digest.hex = digest.hex.chars().rev().collect();
        let foreign_marker = RestoreCommitMarker {
            format_version: RESTORE_MARKER_FORMAT_VERSION,
            backup_id: "backup-foreign".to_string(),
            manifest_digest: digest,
            files: vec!["foreign.key".to_string()],
        };
        fs::write(
            foreign.path().join(LOCAL_RESTORE_COMMIT_MARKER_FILE),
            serde_json::to_vec_pretty(&foreign_marker).expect("marker json"),
        )
        .await
        .expect("seed foreign marker");
        let before = snapshot_tree(foreign.path());
        let error = restore_local_backup(&test_kek(), &restore_request(&bundle, foreign.path()))
            .await
            .expect_err("foreign marker must be rejected");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");
        assert!(error.to_string().contains("different bundle"), "got {error}");
        assert_eq!(snapshot_tree(foreign.path()), before);
    }

    #[tokio::test]
    async fn wrong_passphrase_fails_via_verifier_before_any_target_write() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let mut request = restore_request(&bundle, &target);
        request.master_key = Some("wrong-master-key".to_string());
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("wrong master key must be rejected by the verifier");
        assert!(matches!(error, KmsError::ValidationError { .. }), "got {error:?}");
        assert!(!target.exists(), "the verifier must reject before any target write");
    }

    #[tokio::test]
    async fn wrong_passphrase_fails_via_probe_when_bundle_has_no_verifier() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        // Simulate a bundle produced before the verifier landed.
        reseal_manifest(&bundle, |manifest| {
            manifest
                .local_kdf
                .as_mut()
                .expect("local bundle has a KDF descriptor")
                .master_key_verifier = None;
        })
        .await;

        let mut request = restore_request(&bundle, &target);
        request.master_key = Some("wrong-master-key".to_string());
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("wrong master key must be caught by the decryption probe");
        assert!(matches!(error, KmsError::MaterialAuthenticationFailed { .. }), "got {error:?}");
        assert!(!target.exists(), "the in-memory probe must reject before any target write");

        // The right passphrase still restores the verifier-less bundle.
        restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("verifier-less bundle must restore with the right master key");
        assert_restored_matches_source(&client, &target, &["alpha"]).await;
    }

    #[tokio::test]
    async fn bundle_defects_fail_closed_on_restore() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["victim"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");
        let request = restore_request(&bundle, &target);

        let artifact_file = bundle.join(&manifest.artifacts[0].path);
        let original_artifact = std::fs::read(&artifact_file).expect("artifact bytes");

        // Tampered artifact byte.
        let mut tampered = original_artifact.clone();
        let last = tampered.len() - 1;
        tampered[last] ^= 0x01;
        std::fs::write(&artifact_file, &tampered).expect("write tampered");
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("tampered artifact must be rejected");
        assert!(matches!(error, KmsError::Backup(BackupError::Corrupted { .. })), "got {error:?}");
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::BundleCorrupted),
            "{report:?}"
        );

        // Truncated artifact.
        std::fs::write(&artifact_file, &original_artifact[..original_artifact.len() - 4]).expect("truncate");
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("truncated artifact must be rejected");
        assert!(matches!(error, KmsError::Backup(BackupError::Truncated { .. })), "got {error:?}");
        std::fs::write(&artifact_file, &original_artifact).expect("restore artifact");

        // Wrong KEK identity and wrong KEK material.
        let wrong_id = BackupKek::new("other-kek", 1, [0x42; 32]).expect("kek");
        let error = restore_local_backup(&wrong_id, &request)
            .await
            .expect_err("wrong KEK id must be rejected");
        assert!(matches!(error, KmsError::Backup(BackupError::WrongKek { .. })), "got {error:?}");
        let wrong_material = BackupKek::new("backup-kek-test", 1, [0x24; 32]).expect("kek");
        let error = restore_local_backup(&wrong_material, &request)
            .await
            .expect_err("wrong KEK material must be rejected");
        assert!(matches!(error, KmsError::Backup(BackupError::Corrupted { .. })), "got {error:?}");

        // Missing manifest: the bundle never sealed.
        std::fs::remove_file(bundle.join(LOCAL_BUNDLE_MANIFEST_FILE)).expect("remove manifest");
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("unsealed bundle must be rejected");
        assert!(matches!(error, KmsError::Backup(BackupError::IncompleteBundle { .. })), "got {error:?}");
        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::IncompleteBundle),
            "{report:?}"
        );

        assert!(!target.exists(), "no rejected bundle may touch the target");
    }

    #[tokio::test]
    async fn kdf_parameter_drift_is_rejected() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        reseal_manifest(&bundle, |manifest| {
            let descriptor = manifest.local_kdf.as_mut().expect("local bundle has a KDF descriptor");
            match &mut descriptor.derivation {
                LocalKeyDerivation::Argon2id { memory_kib, .. } => *memory_kib *= 2,
                other => panic!("expected an Argon2id descriptor, got {other:?}"),
            }
        })
        .await;

        let request = restore_request(&bundle, &target);
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("a KDF parameter drift must be rejected");
        assert!(matches!(error, KmsError::UnsupportedCapability { .. }), "got {error:?}");
        assert!(!target.exists());

        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::UnsupportedBackend),
            "{report:?}"
        );
    }

    #[tokio::test]
    async fn unknown_verifier_scheme_fails_closed() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        reseal_manifest(&bundle, |manifest| {
            manifest
                .local_kdf
                .as_mut()
                .expect("local bundle has a KDF descriptor")
                .master_key_verifier = Some("post-quantum-v2:0123abcd".to_string());
        })
        .await;

        let request = restore_request(&bundle, &target);
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("an unknown verifier scheme must fail closed");
        assert!(matches!(error, KmsError::Backup(BackupError::Corrupted { .. })), "got {error:?}");
        assert!(!target.exists());

        let report = dry_run_local_restore(&test_kek(), &request).await.expect("dry run");
        assert!(
            report
                .blockers
                .iter()
                .any(|blocker| blocker.code == RestoreBlockerCode::BundleCorrupted),
            "{report:?}"
        );
    }

    #[tokio::test]
    async fn generation_regression_is_rejected_and_equal_is_allowed() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let mut request = restore_request(&bundle, &target);
        request.observed_generation = Some(SNAPSHOT_GENERATION + 1);
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("a strictly lower bundle generation must be rejected");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");
        assert!(!target.exists());

        request.observed_generation = Some(SNAPSHOT_GENERATION);
        restore_local_backup(&test_kek(), &request)
            .await
            .expect("an equal generation must stay restorable for repeated drills");
        assert_restored_matches_source(&client, &target, &["alpha"]).await;
    }

    #[tokio::test]
    async fn dev_mode_bundle_round_trips_without_passphrase() {
        let (client, _source_dir) = source_with_keys(None, &["dev-key"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let kdf = manifest.local_kdf.as_ref().expect("local kdf descriptor");
        assert_eq!(kdf.master_key_verifier, None, "dev-mode bundles have no master key to verify");

        let mut request = restore_request(&bundle, &target);
        request.master_key = None;
        let report = restore_local_backup(&test_kek(), &request)
            .await
            .expect("dev-mode restore must succeed without a passphrase");
        assert!(!report.salt_restored);
        assert_eq!(report.restored_key_ids, vec!["dev-key".to_string()]);

        let source_bytes = fs::read(client.key_directory().join("dev-key.key")).await.expect("source");
        let restored_bytes = fs::read(target.join("dev-key.key")).await.expect("restored");
        assert_eq!(source_bytes, restored_bytes);

        let restored = LocalKmsClient::new(local_config(&target, None))
            .await
            .expect("restored dev directory must boot");
        restored.describe_key("dev-key", None).await.expect("restored key describes");
    }

    #[tokio::test]
    async fn legacy_sha256_bundle_round_trips() {
        // A pre-beta.9 directory: legacy SHA-256 encrypted record, no salt,
        // no protection marker (same fixture as the backend's beta.5 test;
        // the material decrypts to 32 x 0x42 under this master key).
        let source_dir = TempDir::new().expect("legacy dir");
        let master_key = "beta5-test-master-key";
        let record = serde_json::json!({
            "key_id": "beta5-key",
            "version": 1u32,
            "algorithm": "AES_256",
            "usage": "EncryptDecrypt",
            "status": "Active",
            "description": serde_json::Value::Null,
            "metadata": std::collections::HashMap::<String, String>::new(),
            "created_at": "2024-01-01T00:00:00+00:00",
            "rotated_at": serde_json::Value::Null,
            "created_by": "beta5-fixture",
            "encrypted_key_material": "xjwGa4Lj4qzKg6XQl8s2btyFkPHPChMAkjqs268TFGyvFUv8WjDD5HQCUDLViZmt",
            "nonce": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
        });
        fs::write(
            source_dir.path().join("beta5-key.key"),
            serde_json::to_vec_pretty(&record).expect("record json"),
        )
        .await
        .expect("write legacy record");

        // Export through the read-only constructor so no Argon2 salt gets
        // created: the bundle must record the legacy derivation.
        let client = LocalKmsClient::new_for_key_export(local_config(source_dir.path(), Some(master_key)))
            .await
            .expect("open legacy dir read-only");
        let work = TempDir::new().expect("work dir");
        let (bundle, manifest) = export_bundle(&client, work.path()).await;
        let kdf = manifest.local_kdf.as_ref().expect("local kdf descriptor");
        assert_eq!(kdf.derivation, LocalKeyDerivation::LegacySha256);
        let verifier = kdf.master_key_verifier.as_deref().expect("legacy verifier");
        assert!(verifier.starts_with(MASTER_KEY_VERIFIER_LEGACY_PREFIX), "got {verifier:?}");

        let target = work.path().join("target");
        let mut request = restore_request(&bundle, &target);
        request.master_key = Some("wrong-master-key".to_string());
        let error = restore_local_backup(&test_kek(), &request)
            .await
            .expect_err("the legacy verifier must reject a wrong master key");
        assert!(matches!(error, KmsError::ValidationError { .. }), "got {error:?}");
        assert!(!target.exists());

        request.master_key = Some(master_key.to_string());
        let report = restore_local_backup(&test_kek(), &request).await.expect("legacy restore");
        assert!(!report.salt_restored, "a legacy bundle carries no salt");

        // The restored directory boots (creating a fresh Argon2 salt is
        // legitimate for legacy records) and the material still decrypts via
        // the legacy fallback.
        let restored = LocalKmsClient::new(local_config(&target, Some(master_key)))
            .await
            .expect("restored legacy directory must boot");
        let material = restored
            .decrypt_key_material_for_export("beta5-key")
            .await
            .expect("legacy material must decrypt after restore");
        assert_eq!(material.as_ref(), &[0x42; 32]);
    }

    #[tokio::test]
    async fn crash_during_staging_keeps_top_level_untouched_and_rerun_converges() {
        use durable_file::{CommitStep, failpoint};

        for step in [
            CommitStep::TempWritten,
            CommitStep::FileSynced,
            CommitStep::Published,
            CommitStep::DirSynced,
        ] {
            let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
            let work = TempDir::new().expect("work dir");
            let (bundle, _manifest) = export_bundle(&client, work.path()).await;
            let target = work.path().join("target");
            let request = restore_request(&bundle, &target);

            failpoint::arm(&target, step);
            let error = restore_local_backup(&test_kek(), &request)
                .await
                .expect_err("armed staging commit must simulate a crash");
            failpoint::disarm(&target);
            assert!(error.to_string().contains("injected crash"), "step {step:?}: {error}");

            // Top level: only the staging directory, never a marker or a key.
            assert_eq!(
                top_level_names(&target).await,
                vec![RESTORE_STAGING_DIR.to_string()],
                "step {step:?}: a staging crash must leave the top level untouched"
            );

            restore_local_backup(&test_kek(), &request)
                .await
                .unwrap_or_else(|error| panic!("step {step:?}: re-run must converge: {error}"));
            assert_restored_matches_source(&client, &target, &["alpha"]).await;
        }
    }

    /// Drive the phases manually up to the marker commit, crash the commit at
    /// every step, and prove both re-entry outcomes: an unpublished marker
    /// temp is removable leftover state, a published marker rolls forward.
    #[tokio::test]
    async fn crash_around_marker_publish_recovers() {
        use durable_file::{CommitStep, failpoint};

        for step in [
            CommitStep::TempWritten,
            CommitStep::FileSynced,
            CommitStep::Published,
            CommitStep::DirSynced,
        ] {
            let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha", "beta"]).await;
            let work = TempDir::new().expect("work dir");
            let (bundle, _manifest) = export_bundle(&client, work.path()).await;
            let target = work.path().join("target");
            let request = restore_request(&bundle, &target);

            // Stage everything through the real phase functions.
            let decoded = decode_bundle(&bundle, &test_kek()).await.expect("decode bundle");
            let staging = target.join(RESTORE_STAGING_DIR);
            fs::create_dir_all(&staging).await.expect("create staging");
            let salt = decoded.salt.as_ref().expect("bundle salt");
            stage_file(&staging, LOCAL_KMS_MASTER_KEY_SALT_FILE, salt, Some(0o600))
                .await
                .expect("stage salt");
            for record in &decoded.records {
                stage_file(&staging, &record.file_name, &record.plaintext, Some(0o600))
                    .await
                    .expect("stage record");
            }
            let marker = RestoreCommitMarker::for_bundle(&decoded);

            failpoint::arm(&target, step);
            let error = publish_marker(&target, &marker, Some(0o600))
                .await
                .expect_err("armed marker commit must simulate a crash");
            failpoint::disarm(&target);
            assert!(error.to_string().contains("injected crash"), "step {step:?}: {error}");

            let marker_path = target.join(LOCAL_RESTORE_COMMIT_MARKER_FILE);
            let published = matches!(step, CommitStep::Published | CommitStep::DirSynced);
            assert_eq!(marker_path.exists(), published, "step {step:?}");
            if !published {
                // The unpublished marker temp is exactly the shape startup
                // recovery and a restore re-run both classify as removable.
                let temp = top_level_names(&target)
                    .await
                    .into_iter()
                    .find(|name| is_orphan_commit_temp_name(name));
                assert!(temp.is_some(), "step {step:?}: expected a leftover marker temp");
            } else {
                // A published marker makes backend startup fail closed.
                let error = match LocalKmsClient::new(local_config(&target, Some(TEST_MASTER_KEY))).await {
                    Ok(_) => panic!("step {step:?}: startup must fail closed on a published marker"),
                    Err(error) => error,
                };
                assert!(matches!(error, KmsError::ConfigurationError { .. }), "step {step:?}: {error:?}");
                assert!(error.to_string().contains("unfinished restore"), "step {step:?}: {error}");
            }

            let report = restore_local_backup(&test_kek(), &request)
                .await
                .unwrap_or_else(|error| panic!("step {step:?}: re-run must converge: {error}"));
            assert_eq!(report.resumed, published, "step {step:?}");
            assert_restored_matches_source(&client, &target, &["alpha", "beta"]).await;
        }
    }

    #[tokio::test]
    async fn crash_mid_cutover_fails_startup_then_rolls_forward_or_aborts() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha", "beta"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;

        // Reconstruct the exact state of a cutover killed after k of n links.
        let build_partial_cutover = |target: PathBuf| {
            let bundle = bundle.clone();
            async move {
                let decoded = decode_bundle(&bundle, &test_kek()).await.expect("decode bundle");
                let staging = target.join(RESTORE_STAGING_DIR);
                fs::create_dir_all(&staging).await.expect("create staging");
                stage_file(
                    &staging,
                    LOCAL_KMS_MASTER_KEY_SALT_FILE,
                    decoded.salt.as_ref().expect("bundle salt"),
                    Some(0o600),
                )
                .await
                .expect("stage salt");
                for record in &decoded.records {
                    stage_file(&staging, &record.file_name, &record.plaintext, Some(0o600))
                        .await
                        .expect("stage record");
                }
                let marker = RestoreCommitMarker::for_bundle(&decoded);
                publish_marker(&target, &marker, Some(0o600)).await.expect("publish marker");
                // First link only (the salt), then "power loss".
                durable_file::link_durably(
                    staging.join(LOCAL_KMS_MASTER_KEY_SALT_FILE),
                    target.join(LOCAL_KMS_MASTER_KEY_SALT_FILE),
                )
                .await
                .expect("link salt");
            }
        };

        // Outcome 1: roll forward with the same bundle.
        let target = work.path().join("target-forward");
        build_partial_cutover(target.clone()).await;
        let error = match LocalKmsClient::new(local_config(&target, Some(TEST_MASTER_KEY))).await {
            Ok(_) => panic!("startup must fail closed mid-cutover"),
            Err(error) => error,
        };
        assert!(error.to_string().contains("unfinished restore"), "got {error}");
        assert!(matches!(
            LocalKmsClient::new_for_key_export(local_config(&target, Some(TEST_MASTER_KEY))).await,
            Err(KmsError::ConfigurationError { .. })
        ));
        let report = restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("roll-forward must converge");
        assert!(report.resumed);
        assert_restored_matches_source(&client, &target, &["alpha", "beta"]).await;

        // Outcome 2: explicit abort returns the complete old (empty) state.
        let target = work.path().join("target-abort");
        build_partial_cutover(target.clone()).await;
        abort_local_restore(&target).await.expect("abort must succeed");
        assert_eq!(
            top_level_names(&target).await,
            Vec::<String>::new(),
            "abort must take back every published file, the marker, and staging"
        );
        // The aborted target is a healthy empty directory again: both a fresh
        // backend start and a fresh restore work.
        restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("a fresh restore after abort must succeed");
        assert_restored_matches_source(&client, &target, &["alpha", "beta"]).await;
    }

    #[tokio::test]
    async fn roll_forward_requires_identical_file_set() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha", "beta"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");

        let decoded = decode_bundle(&bundle, &test_kek()).await.expect("decode bundle");
        fs::create_dir_all(target.join(RESTORE_STAGING_DIR))
            .await
            .expect("create staging");
        let mut marker = RestoreCommitMarker::for_bundle(&decoded);
        marker.files.pop();
        publish_marker(&target, &marker, Some(0o600)).await.expect("publish marker");

        let error = restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect_err("a marker with a diverging file set must fail closed");
        assert!(matches!(error, KmsError::Backup(BackupError::Corrupted { .. })), "got {error:?}");
        assert!(error.to_string().contains("different file set"), "got {error}");
    }

    #[tokio::test]
    async fn completed_restore_tolerates_inert_staging_leftover() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = work.path().join("target");
        restore_local_backup(&test_kek(), &restore_request(&bundle, &target))
            .await
            .expect("restore");

        // A crash between marker removal and staging cleanup leaves an inert
        // staging directory; startup must ignore it entirely.
        let staging = target.join(RESTORE_STAGING_DIR);
        fs::create_dir_all(&staging).await.expect("recreate staging");
        fs::write(staging.join("leftover"), b"inert").await.expect("seed leftover");

        let restored = LocalKmsClient::new(local_config(&target, Some(TEST_MASTER_KEY)))
            .await
            .expect("startup must ignore a leftover staging directory");
        restored.describe_key("alpha", None).await.expect("key describes");
        assert!(staging.exists(), "startup must not touch the staging leftover");
    }

    #[tokio::test]
    async fn startup_removes_unpublished_marker_temp() {
        let (client, source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        drop(client);
        let temp_name = format!("{LOCAL_RESTORE_COMMIT_MARKER_FILE}.tmp-{}", uuid::Uuid::new_v4());
        let temp_path = source_dir.path().join(&temp_name);
        fs::write(&temp_path, b"unpublished marker").await.expect("seed marker temp");

        let client = LocalKmsClient::new(local_config(source_dir.path(), Some(TEST_MASTER_KEY)))
            .await
            .expect("an unpublished marker temp must not block startup");
        assert!(!temp_path.exists(), "startup recovery must remove the unpublished marker temp");
        client.describe_key("alpha", None).await.expect("key describes");
    }

    #[tokio::test]
    async fn abort_without_restore_in_progress_errors_and_staging_only_is_cleaned() {
        let empty = TempDir::new().expect("empty dir");
        let error = abort_local_restore(empty.path())
            .await
            .expect_err("nothing to abort must be an error");
        assert!(matches!(error, KmsError::InvalidOperation { .. }), "got {error:?}");

        let staging_only = TempDir::new().expect("staging dir");
        let staging = staging_only.path().join(RESTORE_STAGING_DIR);
        fs::create_dir_all(&staging).await.expect("create staging");
        fs::write(staging.join("partial.key"), b"partial")
            .await
            .expect("seed partial");
        abort_local_restore(staging_only.path())
            .await
            .expect("a pre-marker abort only removes staging");
        assert_eq!(top_level_names(staging_only.path()).await, Vec::<String>::new());
    }

    /// A bundle carrying the sanitized configuration artifact stays fully
    /// restorable, and the configuration never becomes target state: restore
    /// verifies it and moves on, leaving the decision to the admin layer.
    #[tokio::test]
    async fn a_config_artifact_is_verified_but_never_restored() {
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let bundle_parent = TempDir::new().expect("bundle parent");
        let config_bytes = br#"{"backend":"local","note":"sanitized"}"#.to_vec();
        let (bundle, manifest) = export_bundle_with_config(&client, bundle_parent.path(), Some(config_bytes.clone())).await;
        drop(client);

        assert!(
            manifest
                .artifacts
                .iter()
                .any(|artifact| artifact.kind == ArtifactKind::KmsConfig),
            "the bundle must carry the configuration artifact"
        );

        let target = TempDir::new().expect("target");
        let report = dry_run_local_restore(&test_kek(), &restore_request(&bundle, target.path()))
            .await
            .expect("dry-run should evaluate a bundle with a config artifact");
        assert!(report.blockers.is_empty(), "unexpected blockers: {:?}", report.blockers);

        let mut request = restore_request(&bundle, target.path());
        request.conflict_policy = RestoreConflictPolicy::RestoreIntoEmptyTarget;
        let report = restore_local_backup(&test_kek(), &request)
            .await
            .expect("restore should succeed");
        assert_eq!(report.restored_key_ids, vec!["alpha".to_string()]);

        // Only key material and the salt land in the target; the configuration
        // is evidence that stays in the bundle.
        let names = top_level_names(target.path()).await;
        assert!(names.contains(&"alpha.key".to_string()), "{names:?}");
        for name in &names {
            assert!(
                !name.contains("config"),
                "restore must not write a configuration into the key directory: {names:?}"
            );
        }
        let restored = std::fs::read(target.path().join("alpha.key")).expect("restored record");
        assert_ne!(restored, config_bytes);

        // A tampered config artifact must still fail the bundle closed.
        let descriptor = manifest
            .artifacts
            .iter()
            .find(|artifact| artifact.kind == ArtifactKind::KmsConfig)
            .expect("config artifact");
        let artifact_path = bundle.join(&descriptor.path);
        let mut payload = std::fs::read(&artifact_path).expect("artifact bytes");
        let last = payload.len() - 1;
        payload[last] ^= 0xff;
        std::fs::write(&artifact_path, &payload).expect("tamper artifact");

        let fresh_target = TempDir::new().expect("target");
        dry_run_local_restore(&test_kek(), &restore_request(&bundle, fresh_target.path()))
            .await
            .expect("dry-run reports rather than errors")
            .blockers
            .iter()
            .find(|blocker| blocker.code == RestoreBlockerCode::BundleCorrupted)
            .expect("a tampered config artifact must be reported as corruption");
    }

    /// A bundled record from a newer build is not a damaged bundle: the bytes
    /// are intact and the build that wrote them reads them back. Reporting it
    /// as corruption sends the operator into a disaster recovery for what a
    /// version change fixes, so the verdict must stay separate all the way
    /// out to the dry-run blocker code.
    #[test]
    fn bundled_record_from_a_newer_build_is_not_reported_as_corruption() {
        let record = serde_json::json!({
            "key_id": "alpha",
            "at_rest_protection": "secret-marker-value-must-not-leak",
            "encrypted_key_material": "AAAAAAAAAAAAAAAAAAAAAA==",
            "nonce": vec![0u8; 12],
        });
        let error = match decode_key_record(
            "artifacts/keys/alpha.key.enc",
            Zeroizing::new(serde_json::to_vec(&record).expect("encode record")),
            &[AtRestProtection::EncryptedMasterKey],
        ) {
            Ok(_) => panic!("a record this build cannot interpret must be rejected"),
            Err(error) => error,
        };

        let KmsError::Backup(inner) = &error else {
            panic!("expected a backup error, got {error:?}");
        };
        assert!(
            matches!(inner, BackupError::UnsupportedRecordVersion { key_id, version }
                if key_id == "alpha" && version == UNKNOWN_STORED_KEY_PROTECTION),
            "got {inner:?}"
        );
        assert!(!error.to_string().contains("secret-marker-value-must-not-leak"));
        assert_eq!(
            RestoreBlocker::from(inner).code,
            RestoreBlockerCode::UnknownFormatVersion,
            "a dry run must report a version blocker, not a corruption blocker"
        );
    }

    #[test]
    fn bundled_record_format_version_from_a_newer_build_is_not_reported_as_corruption() {
        let record = serde_json::json!({"format_version": 99});
        let error = match decode_key_record(
            "artifacts/keys/alpha.key.enc",
            Zeroizing::new(serde_json::to_vec(&record).expect("encode record")),
            &[AtRestProtection::EncryptedMasterKey],
        ) {
            Ok(_) => panic!("a record this build cannot interpret must be rejected"),
            Err(error) => error,
        };

        let KmsError::Backup(inner) = &error else {
            panic!("expected a backup error, got {error:?}");
        };
        assert!(
            matches!(inner, BackupError::UnsupportedFormatVersion { key_id, version }
                if key_id == "alpha" && version == "99"),
            "got {inner:?}"
        );
        assert_eq!(
            RestoreBlocker::from(inner).code,
            RestoreBlockerCode::UnknownFormatVersion,
            "a dry run must report a version blocker, not a corruption blocker"
        );
    }

    /// The commit marker's format-version branch, symmetric with the Vault
    /// restore marker's: an unknown version is refused outright everywhere the
    /// marker is read, and the backend refuses to start while it is present.
    #[tokio::test]
    async fn restore_commit_marker_from_a_newer_build_is_refused() {
        let mut marker = serde_json::to_value(RestoreCommitMarker {
            format_version: RESTORE_MARKER_FORMAT_VERSION,
            backup_id: BACKUP_ID.to_string(),
            manifest_digest: ContentDigest::sha256_of(b"manifest"),
            files: vec![LOCAL_KMS_MASTER_KEY_SALT_FILE.to_string()],
        })
        .expect("marker json");
        marker["format_version"] = serde_json::json!(99);
        let marker = serde_json::to_vec_pretty(&marker).expect("marker bytes");

        let error = RestoreCommitMarker::decode(&marker).expect_err("an unknown marker version must be refused");
        assert!(error.to_string().contains("unknown format version 99"), "got {error}");

        // Reachable from the restore entry point, not just from the decoder.
        let (client, _source_dir) = source_with_keys(Some(TEST_MASTER_KEY), &["alpha"]).await;
        let work = TempDir::new().expect("work dir");
        let (bundle, _manifest) = export_bundle(&client, work.path()).await;
        let target = TempDir::new().expect("target dir");
        fs::write(target.path().join(LOCAL_RESTORE_COMMIT_MARKER_FILE), &marker)
            .await
            .expect("seed marker");
        let before = snapshot_tree(target.path());
        let error = restore_local_backup(&test_kek(), &restore_request(&bundle, target.path()))
            .await
            .expect_err("an unknown marker version must block the restore");
        assert!(error.to_string().contains("unknown format version 99"), "got {error}");
        assert_eq!(snapshot_tree(target.path()), before, "the refused restore must not write");

        // And the backend itself refuses to start on any marker at all.
        let error = match LocalKmsClient::new(local_config(target.path(), Some(TEST_MASTER_KEY))).await {
            Ok(_) => panic!("a restore marker must block startup"),
            Err(error) => error,
        };
        assert!(error.to_string().contains("unfinished restore"), "got {error}");
    }
}
