use super::error::LifecycleError;
use super::keystore::{
Key, KeySource, KeyStoreError, KeyStoreErrorKind, PendingRevision, ProtectedKeySession,
};
use super::store::{
db_unavailable, file_present, is_plaintext_sqlite, key_store_error, probe, scan_candidates,
EncryptedStore, EncryptedStoreConfig, PendingRead, Probe, Scan, SlotRead,
};
use super::transition::{
next_id, read_journal, remove_journal, Journal, JournalState, RootIdentity, StoreTransition,
TransitionKind,
};
use crate::{DbError, IntegrityCheck, InterruptedRestore};
use rusqlite::OpenFlags;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Phase {
Guarded,
OldVerified,
OldRetained,
NewStaged,
Rekeying,
Rekeyed,
Promoted,
Cleaned,
Settled,
}
impl Phase {
pub const ALL: [Phase; 9] = [
Phase::Guarded,
Phase::OldVerified,
Phase::OldRetained,
Phase::NewStaged,
Phase::Rekeying,
Phase::Rekeyed,
Phase::Promoted,
Phase::Cleaned,
Phase::Settled,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Guarded => "guarded",
Self::OldVerified => "old-verified",
Self::OldRetained => "old-retained",
Self::NewStaged => "new-staged",
Self::Rekeying => "rekeying",
Self::Rekeyed => "rekeyed",
Self::Promoted => "promoted",
Self::Cleaned => "cleaned",
Self::Settled => "settled",
}
}
pub fn parse(text: &str) -> Option<Self> {
Self::ALL.into_iter().find(|phase| phase.as_str() == text)
}
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Killed(pub Phase);
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RekeyStep {
Forward,
Rollback,
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RekeyFault {
None,
FailBefore,
FailAfter,
}
#[doc(hidden)]
pub trait Faults: Send + Sync {
fn die_after(&self, phase: Phase) -> bool {
let _ = phase;
false
}
fn rekey(&self, step: RekeyStep) -> RekeyFault {
let _ = step;
RekeyFault::None
}
fn before_readback(&self, db: &Path) {
let _ = db;
}
fn before_pool_registration(&self) {}
}
pub(super) struct NoFaults;
impl Faults for NoFaults {}
pub struct RotationRequest {
pub new_key: Key,
pub retain_previous: bool,
}
impl RotationRequest {
pub fn new(new_key: impl Into<String>) -> Self {
Self {
new_key: Key::new(new_key.into()),
retain_previous: false,
}
}
pub fn retaining_previous(mut self) -> Self {
self.retain_previous = true;
self
}
}
impl fmt::Debug for RotationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RotationRequest")
.field("new_key", &"<redacted>")
.field("retain_previous", &self.retain_previous)
.finish()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RotationReport {
Committed,
CommittedCleanupPending { pending: Vec<String> },
NotCommitted {
error: LifecycleError,
cleanup_pending: Vec<String>,
},
Indeterminate { detail: String },
}
impl RotationReport {
pub fn is_committed(&self) -> bool {
matches!(self, Self::Committed | Self::CommittedCleanupPending { .. })
}
}
fn not_committed(error: LifecycleError) -> RotationReport {
RotationReport::NotCommitted {
error,
cleanup_pending: Vec::new(),
}
}
#[derive(Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoveryStep {
PendingDiscarded,
PendingPromoted,
LegacyStagingMigrated {
alias: String,
},
LegacyStagingDeleted {
alias: String,
},
PrimaryRestoredFromRecovery,
RecoverySlotCleared,
JournalCleared,
RestoreRecovered(InterruptedRestore),
RelocationAbandoned {
old_root: PathBuf,
new_root: Option<PathBuf>,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum BlockedReason {
StoreMissing,
NotEncrypted,
KeyMissing,
KeyStoreUnavailable {
slot: String,
error: KeyStoreError,
},
NoKeyOpens {
tried: Vec<KeySource>,
},
DatabaseUnavailable(DbError),
Restore(String),
Journal(String),
Busy(String),
Other(String),
}
impl BlockedReason {
pub(super) fn into_error(self, path: &Path) -> LifecycleError {
match self {
Self::StoreMissing => LifecycleError::StoreMissing {
path: path.to_path_buf(),
},
Self::NotEncrypted => LifecycleError::NotEncrypted {
path: path.to_path_buf(),
},
Self::KeyMissing => LifecycleError::KeyMissing,
Self::KeyStoreUnavailable { slot, error } => LifecycleError::key_store(slot, error),
Self::NoKeyOpens { tried } => LifecycleError::NoKeyOpens {
path: path.to_path_buf(),
tried,
},
Self::DatabaseUnavailable(error) => db_unavailable(path, error),
Self::Restore(detail) | Self::Journal(detail) | Self::Other(detail) => {
LifecycleError::RecoveryRequired { detail }
}
Self::Busy(reason) => LifecycleError::Busy { reason },
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RecoveryOutcome {
Consistent,
Reconciled,
CleanupPending,
Degraded,
Blocked(BlockedReason),
}
#[derive(Debug)]
pub struct RecoveryReport {
pub outcome: RecoveryOutcome,
pub opened_with: Option<KeySource>,
pub steps: Vec<RecoveryStep>,
pub pending_cleanup: Vec<String>,
}
impl RecoveryReport {
fn new() -> Self {
Self {
outcome: RecoveryOutcome::Consistent,
opened_with: None,
steps: Vec::new(),
pending_cleanup: Vec::new(),
}
}
fn blocked(reason: BlockedReason) -> Self {
let mut report = Self::new();
report.outcome = RecoveryOutcome::Blocked(reason);
report
}
pub(super) fn from_error(error: LifecycleError) -> Self {
let reason = if error.is_busy() {
BlockedReason::Busy(error.to_string())
} else {
BlockedReason::Other(error.to_string())
};
Self::blocked(reason)
}
pub fn is_blocked(&self) -> bool {
matches!(self.outcome, RecoveryOutcome::Blocked(_))
}
}
impl EncryptedStore {
pub fn rotate_key(&self, request: RotationRequest) -> RotationReport {
match rotate_inner(self, request, &NoFaults) {
Ok(report) => report,
Err(Killed(phase)) => RotationReport::Indeterminate {
detail: format!("stopped after {}", phase.as_str()),
},
}
}
#[doc(hidden)]
pub fn rotate_key_with_faults(
&self,
request: RotationRequest,
faults: &dyn Faults,
) -> Result<RotationReport, Killed> {
rotate_inner(self, request, faults)
}
pub fn recover_rotation(&self) -> RecoveryReport {
let cfg = self.config();
if let Err(error) = self.drain(TransitionKind::Recover, true) {
return RecoveryReport::from_error(error);
}
let report = match recover_with_guard(&cfg, &NoFaults) {
Ok(report) => report,
Err(error) => RecoveryReport::from_error(error),
};
let _ = self.resume();
report
}
}
pub fn recover_rotation(config: &EncryptedStoreConfig) -> RecoveryReport {
let config = match config.clone().checked() {
Ok(config) => config,
Err(error) => return RecoveryReport::from_error(error),
};
match recover_with_guard(&config, &NoFaults) {
Ok(report) => report,
Err(error) => RecoveryReport::from_error(error),
}
}
enum End {
Committed(Vec<String>),
NotCommitted(LifecycleError, Vec<String>),
Indeterminate(String),
}
fn rotate_inner(
store: &EncryptedStore,
request: RotationRequest,
faults: &dyn Faults,
) -> Result<RotationReport, Killed> {
if let Err(error) = crate::validate_key(Some(request.new_key.as_str())) {
return Ok(not_committed(LifecycleError::Db(error)));
}
let cfg = store.config();
match read_journal(&cfg.db.path) {
Ok(Some(journal)) if journal.kind == TransitionKind::Relocate => {
return Ok(not_committed(LifecycleError::RecoveryRequired {
detail: "relocation requires explicit authoritative settlement".into(),
}));
}
Err(error) => return Ok(not_committed(error)),
_ => {}
}
let (old, source, degraded) = match store.snapshot() {
Ok(snapshot) => snapshot,
Err(error) => return Ok(not_committed(error)),
};
if source != KeySource::Primary {
return Ok(not_committed(LifecycleError::Precondition(
"the store was opened with a key that is not the canonical primary key; call recover_rotation first"
.into(),
)));
}
if let Some(note) = degraded {
return Ok(not_committed(LifecycleError::Precondition(format!(
"key storage has unfinished cleanup ({note}); call recover_rotation first"
))));
}
if old.as_str() == request.new_key.as_str() {
return Ok(not_committed(LifecycleError::Precondition(
"the new key equals the current key".into(),
)));
}
if let Err(error) = store.drain(TransitionKind::Rotate, false) {
return Ok(not_committed(error));
}
let transition = match StoreTransition::begin(
TransitionKind::Rotate,
&[cfg.db.path.as_path()],
&cfg.transition,
) {
Ok(transition) => transition,
Err(error) => {
let _ = store.resume();
return Ok(not_committed(error));
}
};
match read_journal(&cfg.db.path) {
Ok(Some(journal)) if journal.kind == TransitionKind::Relocate => {
drop(transition);
store.block("relocation requires explicit authoritative settlement".into());
return Ok(not_committed(LifecycleError::RecoveryRequired {
detail: "relocation remains pending".into(),
}));
}
Err(error) => {
drop(transition);
store.block(error.to_string());
return Ok(not_committed(error));
}
_ => {}
}
let rotation = match cfg.key_store.lock_rotation(cfg.transition.acquire_timeout) {
Ok(lock) => lock,
Err(error) => {
drop(transition);
let _ = store.resume();
return Ok(not_committed(key_store_error("rotation lock", error)));
}
};
let journal = Journal {
kind: TransitionKind::Rotate,
state: JournalState::Active,
phase: Phase::Guarded.as_str().to_string(),
retain_previous: request.retain_previous,
pending: None,
id: next_id(),
old_root: RootIdentity::capture(&cfg.db.path),
new_root: None,
relocation: None,
};
let mut run = Run {
cfg: &cfg,
ks: rotation,
faults,
tr: transition,
path: cfg.db.effective_path(),
old,
new: request.new_key.clone(),
retain: request.retain_previous,
journal,
rev: None,
prior_recovery: None,
recovery_written: false,
pending_ours: false,
cleanup: Vec::new(),
};
let end = run.execute()?;
drop(run);
Ok(match end {
End::Indeterminate(detail) => {
store.block(detail.clone());
RotationReport::Indeterminate { detail }
}
End::NotCommitted(error, cleanup_pending) => {
let _ = store.resume();
RotationReport::NotCommitted {
error,
cleanup_pending,
}
}
End::Committed(pending) => {
let _ = store.resume();
if pending.is_empty() {
RotationReport::Committed
} else {
RotationReport::CommittedCleanupPending { pending }
}
}
})
}
struct Run<'a> {
cfg: &'a EncryptedStoreConfig,
ks: Box<dyn ProtectedKeySession + Send + 'static>,
faults: &'a dyn Faults,
tr: StoreTransition,
path: PathBuf,
old: Key,
new: Key,
retain: bool,
journal: Journal,
rev: Option<PendingRevision>,
prior_recovery: Option<Key>,
recovery_written: bool,
pending_ours: bool,
cleanup: Vec<String>,
}
impl<'a> Run<'a> {
fn persist(&mut self, phase: Phase) -> Result<(), LifecycleError> {
self.journal.phase = phase.as_str().to_string();
self.tr.write_journal(&self.journal)
}
fn reached(&self, phase: Phase) -> Result<(), Killed> {
if self.faults.die_after(phase) {
Err(Killed(phase))
} else {
Ok(())
}
}
fn probe_key(&self, key: &Key) -> Probe {
probe(
&self.cfg.db,
&self.path,
key,
IntegrityCheck::Quick,
OpenFlags::SQLITE_OPEN_READ_WRITE,
)
}
fn abort(&mut self, error: LifecycleError) -> End {
if matches!(&error, LifecycleError::KeyStore { source, .. }
if source.kind == KeyStoreErrorKind::Unsettled)
{
return self.indeterminate(format!("protected key mutation has not settled: {error}"));
}
let mut left = Vec::new();
if self.pending_ours {
match self.ks.read_pending() {
Ok(Some(pending)) => {
if let Err(discard) = self.ks.discard_pending(&pending.revision) {
if discard.kind == KeyStoreErrorKind::Unsettled {
return self.indeterminate(format!(
"pending cleanup has not settled: {discard}"
));
}
left.push(format!("discard the staged key: {discard}"));
}
}
Ok(None) => {}
Err(read) => left.push(format!("read the staged key: {read}")),
}
}
if self.recovery_written && left.is_empty() {
let restored = match &self.prior_recovery {
Some(prior) => self.ks.write_recovery(prior),
None => self.ks.clear_recovery(),
};
if let Err(restore) = restored {
if restore.kind == KeyStoreErrorKind::Unsettled {
return self
.indeterminate(format!("recovery restoration has not settled: {restore}"));
}
left.push(format!("restore the recovery slot: {restore}"));
}
}
if left.is_empty() {
if let Err(clear) = self.tr.clear_journal() {
left.push(format!("remove the transition journal: {clear}"));
}
}
End::NotCommitted(error, left)
}
fn indeterminate(&mut self, detail: String) -> End {
self.journal.state = JournalState::Indeterminate;
let _ = self.tr.write_journal(&self.journal);
End::Indeterminate(detail)
}
fn run_rekey(&self, from: &Key, to: &Key, step: RekeyStep) -> Option<DbError> {
let fault = self.faults.rekey(step);
if fault == RekeyFault::FailBefore {
return Some(DbError::Backup("injected rekey failure".into()));
}
let mut options = self.cfg.keyed(from);
options.integrity = IntegrityCheck::Off;
options.integrity_check_on_open = false;
let conn = match options.open_existing_at(&self.path, OpenFlags::SQLITE_OPEN_READ_WRITE) {
Ok(conn) => conn,
Err(error) => return Some(error),
};
let result = crate::rekey(&conn, to.as_str());
drop(conn);
match result {
Err(error) => Some(error),
Ok(()) if fault == RekeyFault::FailAfter => {
Some(DbError::Backup("injected failure after the rekey".into()))
}
Ok(()) => None,
}
}
fn rollback(&mut self, error: LifecycleError) -> End {
let rekey_error = self.run_rekey(&self.new, &self.old, RekeyStep::Rollback);
match self.probe_key(&self.old) {
Probe::Opens => match self.ks.read_primary() {
Ok(Some(primary)) if primary.as_str() == self.old.as_str() => self.abort(error),
Ok(_) => self.indeterminate(
"the primary key changed while the database was rolled back".into(),
),
Err(read) => self.indeterminate(format!(
"the primary key could not be read back after the rollback: {read}"
)),
},
_ => {
let reported =
rekey_error.map_or_else(|| "no error".to_string(), |e| e.to_string());
self.indeterminate(format!(
"the database could not be rolled back to the old key ({reported}); the new key stays staged as the pending key and the old key stays retained"
))
}
}
}
fn execute(&mut self) -> Result<End, Killed> {
let cfg = self.cfg;
if let Err(error) = self.persist(Phase::Guarded) {
return Ok(self.abort(error));
}
self.reached(Phase::Guarded)?;
match self.probe_key(&self.old) {
Probe::Opens => {}
Probe::KeyRejected => {
return Ok(self.abort(LifecycleError::Precondition(
"the current key no longer opens the database".into(),
)))
}
Probe::Other(error) => {
let path = self.path.clone();
return Ok(self.abort(db_unavailable(&path, error)));
}
}
match self.ks.read_primary() {
Ok(Some(primary)) if primary.as_str() == self.old.as_str() => {}
Ok(_) => {
return Ok(self.abort(LifecycleError::KeyStoreMismatch {
detail: "the primary key in protected storage differs from the key that opens the database"
.into(),
}))
}
Err(error) => return Ok(self.abort(key_store_error("primary", error))),
}
if let Err(error) = self.persist(Phase::OldVerified) {
return Ok(self.abort(error));
}
self.reached(Phase::OldVerified)?;
self.prior_recovery = match self.ks.read_recovery() {
Ok(prior) => prior,
Err(error) => return Ok(self.abort(key_store_error("recovery", error))),
};
self.recovery_written = true;
if let Err(error) = self.ks.write_recovery(&self.old) {
return Ok(self.abort(key_store_error("recovery", error)));
}
match self.ks.read_recovery() {
Ok(Some(read)) if read.as_str() == self.old.as_str() => {}
Ok(_) => {
return Ok(self.abort(LifecycleError::Precondition(
"the previous key could not be read back from the recovery slot".into(),
)))
}
Err(error) => return Ok(self.abort(key_store_error("recovery", error))),
}
if let Err(error) = self.persist(Phase::OldRetained) {
return Ok(self.abort(error));
}
self.reached(Phase::OldRetained)?;
match self.ks.read_pending() {
Ok(None) => {}
Ok(Some(_)) => {
return Ok(self.abort(LifecycleError::Precondition(
"a pending key already exists; call recover_rotation first".into(),
)))
}
Err(error) => return Ok(self.abort(key_store_error("pending", error))),
}
self.pending_ours = true;
let revision = match self.ks.write_pending(&self.new) {
Ok(revision) => revision,
Err(error) => return Ok(self.abort(key_store_error("pending", error))),
};
self.rev = Some(revision.clone());
match self.ks.read_pending() {
Ok(Some(read)) if read.key.as_str() == self.new.as_str() => {}
Ok(_) => {
return Ok(self.abort(LifecycleError::Precondition(
"the staged key could not be read back".into(),
)))
}
Err(error) => return Ok(self.abort(key_store_error("pending", error))),
}
self.journal.pending = Some(revision.0.clone());
if let Err(error) = self.persist(Phase::NewStaged) {
return Ok(self.abort(error));
}
self.reached(Phase::NewStaged)?;
if let Err(error) = self.persist(Phase::Rekeying) {
return Ok(self.abort(error));
}
self.reached(Phase::Rekeying)?;
let rekey_error = self.run_rekey(&self.old, &self.new, RekeyStep::Forward);
self.faults.before_readback(&self.path);
match self.probe_key(&self.new) {
Probe::Opens => {}
Probe::KeyRejected => {
return Ok(match self.probe_key(&self.old) {
Probe::Opens => {
let error = match rekey_error {
Some(error) => LifecycleError::Db(error),
None => LifecycleError::Precondition(
"the rekey reported success but had no effect".into(),
),
};
self.abort(error)
}
Probe::KeyRejected => self.indeterminate(
"the database opens with neither the old nor the new key after the rekey"
.into(),
),
Probe::Other(error) => self.indeterminate(format!(
"the old key could not be checked after the rekey: {error}"
)),
});
}
Probe::Other(error) => {
return Ok(self.indeterminate(format!(
"the new key could not be checked after the rekey: {error}"
)))
}
}
let _ = self.persist(Phase::Rekeyed);
self.reached(Phase::Rekeyed)?;
if let Some(validate) = &cfg.validate {
let options = cfg.keyed(&self.new);
if let Err(reason) = validate(&options) {
return Ok(self.rollback(LifecycleError::ValidationFailed(reason)));
}
}
let Some(revision) = self.rev.clone() else {
return Ok(self.indeterminate("the staged revision was lost".into()));
};
let promoted = self.ks.promote_pending(&revision);
if let Err(error) = &promoted {
if error.kind == KeyStoreErrorKind::Unsettled {
return Ok(self.indeterminate(format!("promotion has not settled: {error}")));
}
}
match self.ks.read_primary() {
Ok(Some(primary)) if primary.as_str() == self.new.as_str() => {}
Ok(Some(primary)) if primary.as_str() == self.old.as_str() => {
let error = match promoted {
Err(error) => key_store_error("promotion", error),
Ok(()) => LifecycleError::Precondition(
"promotion reported success but the primary key did not change".into(),
),
};
return Ok(self.rollback(error));
}
Ok(_) => {
return Ok(self.indeterminate(
"the primary key slot holds an unexpected value after promotion".into(),
))
}
Err(error) => {
return Ok(self.indeterminate(format!(
"the primary key could not be read back after promotion: {error}"
)))
}
}
let _ = self.persist(Phase::Promoted);
self.reached(Phase::Promoted)?;
let canonical = match self.ks.read_primary() {
Ok(Some(primary)) if primary.as_str() == self.new.as_str() => primary,
Ok(_) => {
return Ok(self.indeterminate("the primary key changed after promotion".into()))
}
Err(error) => {
return Ok(self.indeterminate(format!(
"the primary key could not be read for the final check: {error}"
)))
}
};
match self.probe_key(&canonical) {
Probe::Opens => {}
Probe::KeyRejected => {
return Ok(
self.indeterminate("the stored primary key does not open the database".into())
)
}
Probe::Other(error) => {
return Ok(self.indeterminate(format!(
"the final readback could not open the database: {error}"
)))
}
}
match self.ks.read_pending() {
Ok(None) => {}
Ok(Some(pending)) => {
if let Err(error) = self.ks.discard_pending(&pending.revision) {
if error.kind == KeyStoreErrorKind::Unsettled {
return Ok(
self.indeterminate(format!("pending cleanup has not settled: {error}"))
);
}
self.cleanup
.push(format!("discard the pending key: {error}"));
}
}
Err(error) => self.cleanup.push(format!("read the pending key: {error}")),
}
self.journal.state = JournalState::CommittedCleanup;
let _ = self.persist(Phase::Cleaned);
self.reached(Phase::Cleaned)?;
if !self.retain {
if let Err(error) = self.ks.clear_recovery() {
if error.kind == KeyStoreErrorKind::Unsettled {
return Ok(
self.indeterminate(format!("recovery cleanup has not settled: {error}"))
);
}
self.cleanup
.push(format!("clear the recovery slot: {error}"));
}
}
if self.cleanup.is_empty() {
if let Err(error) = self.tr.clear_journal() {
self.cleanup
.push(format!("remove the transition journal: {error}"));
}
}
self.reached(Phase::Settled)?;
Ok(End::Committed(std::mem::take(&mut self.cleanup)))
}
}
pub(super) fn recover_with_guard(
cfg: &EncryptedStoreConfig,
faults: &dyn Faults,
) -> Result<RecoveryReport, LifecycleError> {
let transition = StoreTransition::begin(
TransitionKind::Recover,
&[cfg.db.path.as_path()],
&cfg.transition,
)?;
let report = recover_locked(cfg, faults);
transition.settle();
report
}
fn clear_journals(roots: &[&Path]) -> Result<(), LifecycleError> {
let mut first = None;
for root in roots {
if let Err(error) = remove_journal(root) {
first.get_or_insert(error);
}
}
first.map_or(Ok(()), Err)
}
pub(super) fn make_primary(
ks: &mut dyn ProtectedKeySession,
key: &Key,
staged: Option<&PendingRevision>,
) -> Result<(), KeyStoreError> {
let revision = match staged {
Some(revision) => revision.clone(),
None => ks.write_pending(key)?,
};
let promoted = ks.promote_pending(&revision);
if let Err(error) = &promoted {
if error.kind == KeyStoreErrorKind::Unsettled {
return Err(error.clone());
}
}
match ks.read_primary() {
Ok(Some(primary)) if primary.as_str() == key.as_str() => Ok(()),
Ok(_) => Err(promoted
.err()
.unwrap_or_else(|| KeyStoreError::other("promotion did not change the primary key"))),
Err(error) => Err(error),
}
}
fn blocked_from_scan(scan: Scan) -> BlockedReason {
if let Some(error) = scan.db_error {
return BlockedReason::DatabaseUnavailable(error);
}
if let Some((slot, error)) = scan.failure {
return BlockedReason::KeyStoreUnavailable { slot, error };
}
if scan.candidates == 0 {
return BlockedReason::KeyMissing;
}
BlockedReason::NoKeyOpens { tried: scan.tried }
}
pub(super) fn recover_locked(
cfg: &EncryptedStoreConfig,
faults: &dyn Faults,
) -> Result<RecoveryReport, LifecycleError> {
let _ = faults;
let mut session = cfg
.key_store
.lock_rotation(cfg.transition.acquire_timeout)
.map_err(|error| key_store_error("rotation lock", error))?;
let ks = &mut *session;
let mut report = RecoveryReport::new();
let journal = match read_journal(&cfg.db.path) {
Ok(journal) => journal,
Err(error) => {
report.outcome = RecoveryOutcome::Blocked(BlockedReason::Journal(error.to_string()));
return Ok(report);
}
};
if journal
.as_ref()
.is_some_and(|journal| journal.kind == TransitionKind::Relocate)
{
report.outcome = RecoveryOutcome::Blocked(BlockedReason::Journal(
"relocation requires explicit authoritative settlement through recover_relocation"
.into(),
));
return Ok(report);
}
match crate::recover_interrupted_restore_locked(&cfg.db) {
Ok(Some(restored)) => report.steps.push(RecoveryStep::RestoreRecovered(restored)),
Ok(None) => {}
Err(error) => {
report.outcome = RecoveryOutcome::Blocked(BlockedReason::Restore(error.to_string()));
return Ok(report);
}
}
let path = cfg.db.effective_path();
match file_present(&path) {
Ok(true) => {}
Ok(false) => {
report.outcome = RecoveryOutcome::Blocked(BlockedReason::StoreMissing);
return Ok(report);
}
Err(error) => {
report.outcome = RecoveryOutcome::Blocked(BlockedReason::Other(error.to_string()));
return Ok(report);
}
}
if is_plaintext_sqlite(&path) {
report.outcome = RecoveryOutcome::Blocked(BlockedReason::NotEncrypted);
return Ok(report);
}
let mut scan = scan_candidates(cfg, &path, ks);
let Some((source, key)) = scan.opened.take() else {
report.outcome = RecoveryOutcome::Blocked(blocked_from_scan(scan));
return Ok(report);
};
report.opened_with = Some(source.clone());
let slots = &scan.slots;
let mut degraded: Option<String> = None;
match &source {
KeySource::Primary => {}
KeySource::Pending => {
if let PendingRead::Key(pending) = &slots.pending {
match make_primary(ks, &key, Some(&pending.revision)) {
Ok(()) => report.steps.push(RecoveryStep::PendingPromoted),
Err(reason) => degraded = Some(reason.to_string()),
}
}
}
KeySource::LegacyStaging(_) | KeySource::Recovery => {
if let PendingRead::Key(stale) = &slots.pending {
if let Err(error) = ks.discard_pending(&stale.revision) {
degraded = Some(format!(
"the stale pending key could not be discarded: {error}"
));
}
}
if degraded.is_none() {
match make_primary(ks, &key, None) {
Ok(()) => report.steps.push(match &source {
KeySource::LegacyStaging(alias) => RecoveryStep::LegacyStagingMigrated {
alias: alias.clone(),
},
_ => RecoveryStep::PrimaryRestoredFromRecovery,
}),
Err(reason) => degraded = Some(reason.to_string()),
}
}
}
}
if degraded.is_none() {
match &source {
KeySource::Pending => {
match ks.read_pending() {
Ok(Some(pending)) => match ks.discard_pending(&pending.revision) {
Ok(()) => report.steps.push(RecoveryStep::PendingDiscarded),
Err(error) => report
.pending_cleanup
.push(format!("discard the pending key: {error}")),
},
Ok(None) => {}
Err(error) => report
.pending_cleanup
.push(format!("read the pending key: {error}")),
}
}
_ => match &slots.pending {
PendingRead::Key(pending) => match ks.discard_pending(&pending.revision) {
Ok(()) => report.steps.push(RecoveryStep::PendingDiscarded),
Err(error) => report
.pending_cleanup
.push(format!("discard the pending key: {error}")),
},
PendingRead::Failed(error) => report
.pending_cleanup
.push(format!("read the pending key: {error}")),
PendingRead::Absent => {}
},
}
if report.pending_cleanup.is_empty() && cfg.delete_legacy_after_migration {
let holders: Vec<&String> = slots
.legacy
.iter()
.filter(|(_, slot)| matches!(slot, SlotRead::Key(held) if held.as_str() == key.as_str()))
.map(|(alias, _)| alias)
.collect();
if !holders.is_empty() {
let reopened = match ks.read_primary() {
Ok(Some(primary)) => {
primary.as_str() == key.as_str()
&& matches!(
probe(
&cfg.db,
&path,
&primary,
IntegrityCheck::Quick,
OpenFlags::SQLITE_OPEN_READ_WRITE
),
Probe::Opens
)
}
_ => false,
};
for alias in holders {
if !reopened {
report.pending_cleanup.push(format!(
"the legacy staging item {alias} was kept: the canonical key did not reopen the database"
));
continue;
}
match ks.delete_legacy_staging(alias) {
Ok(()) => report.steps.push(RecoveryStep::LegacyStagingDeleted {
alias: alias.clone(),
}),
Err(error) => report
.pending_cleanup
.push(format!("delete the legacy staging item {alias}: {error}")),
}
}
}
}
let rotation_settled = journal.as_ref().is_some_and(|journal| {
journal.kind == TransitionKind::Rotate
&& !journal.retain_previous
&& Phase::parse(&journal.phase).is_some_and(|phase| phase >= Phase::OldRetained)
});
let from_recovery = matches!(source, KeySource::Recovery);
if report.pending_cleanup.is_empty()
&& (rotation_settled || from_recovery)
&& matches!(slots.recovery, SlotRead::Key(_))
{
match ks.clear_recovery() {
Ok(()) => report.steps.push(RecoveryStep::RecoverySlotCleared),
Err(error) => report
.pending_cleanup
.push(format!("clear the recovery slot: {error}")),
}
}
if report.pending_cleanup.is_empty() && journal.is_some() {
match clear_journals(&[cfg.db.path.as_path()]) {
Ok(()) => report.steps.push(RecoveryStep::JournalCleared),
Err(error) => report
.pending_cleanup
.push(format!("remove the transition journal: {error}")),
}
}
}
report.outcome = if degraded.is_some() {
if let Some(reason) = degraded {
report.pending_cleanup.push(reason);
}
RecoveryOutcome::Degraded
} else if !report.pending_cleanup.is_empty() {
RecoveryOutcome::CleanupPending
} else if report.steps.is_empty() {
RecoveryOutcome::Consistent
} else {
RecoveryOutcome::Reconciled
};
Ok(report)
}