use std::sync::Arc;
use crate::blob::{BlobError, BlobStore};
use crate::case::CaseStore;
use crate::core::StoreError;
use crate::export::CASE_PAGE;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DrillReport {
pub cases: usize,
pub blobs_present: usize,
pub blobs_erased: usize,
pub sealed_open: usize,
pub sealed_erased: usize,
pub findings: Vec<String>,
pub not_checked: Vec<String>,
}
impl DrillReport {
#[must_use]
pub fn is_sound(&self) -> bool {
self.findings.is_empty()
}
}
#[derive(Debug)]
pub struct Stores<'a> {
pub cases: &'a Arc<dyn CaseStore>,
pub blobs: Option<&'a Arc<dyn BlobStore>>,
#[cfg(feature = "keyring")]
pub keys: Option<&'a Arc<dyn crate::keyring::KeyRing>>,
pub tenant: &'a crate::core::TenantId,
}
pub async fn drill(stores: &Stores<'_>) -> Result<DrillReport, StoreError> {
let mut report = DrillReport {
cases: 0,
blobs_present: 0,
blobs_erased: 0,
sealed_open: 0,
sealed_erased: 0,
findings: Vec::new(),
not_checked: Vec::new(),
};
if stores.blobs.is_none() {
report.not_checked.push(
"blob bytes — no blob store was supplied, so presence and integrity of the \
artifacts each case references were not established"
.to_owned(),
);
}
#[cfg(feature = "keyring")]
if stores.keys.is_none() {
report.not_checked.push(
"sealed-state keys — no key ring was supplied, so whether sealed case state \
still opens was not established; if this deployment seals its blobs, their \
envelopes cannot be opened either and will report below as corrupt"
.to_owned(),
);
}
#[cfg(not(feature = "keyring"))]
report.not_checked.push(
"sealed-state keys — this build carries no `keyring` feature, so whether sealed \
case state still opens was not established"
.to_owned(),
);
let mut after = None;
loop {
let page = stores.cases.cases(after, CASE_PAGE).await?;
let Some(last) = page.last() else { break };
after = Some(last.id);
let full = page.len() >= CASE_PAGE;
for case in page {
report.cases += 1;
if let Some(blobs) = stores.blobs {
let scope = crate::core::erasure_scope(stores.tenant, &case.id.to_string());
let scoped: Arc<dyn BlobStore> = Arc::new(crate::blob::ScopedBlobs::new(
Arc::clone(blobs),
scope.clone(),
));
#[cfg(feature = "keyring")]
let handle: Arc<dyn BlobStore> = match stores.keys {
Some(keys) => Arc::new(crate::keyring::EncryptedBlobs::new(
scoped,
Arc::clone(keys),
scope,
)),
None => scoped,
};
#[cfg(not(feature = "keyring"))]
let handle: Arc<dyn BlobStore> = scoped;
check_blobs(&mut report, stores.cases, handle.as_ref(), case.id).await?;
}
#[cfg(feature = "keyring")]
if let Some(keys) = stores.keys {
check_sealed(&mut report, keys.as_ref(), case.id, &case.state).await;
}
}
if !full {
break;
}
}
#[cfg(feature = "keyring")]
if stores.keys.is_some()
&& report.cases > 0
&& report.sealed_open == 0
&& report.sealed_erased == 0
{
report.not_checked.push(
"sealed-state coverage — a key ring was supplied and no case's state was \
sealed, so this pass proved nothing about sealing: either this plane keeps \
case state plaintext by design, or sealing was never wired to the case \
store. The two cannot be told apart from here, and only the second is a \
misconfiguration worth chasing"
.to_owned(),
);
}
Ok(report)
}
async fn check_blobs(
report: &mut DrillReport,
cases: &Arc<dyn CaseStore>,
blobs: &dyn BlobStore,
case: crate::core::CaseId,
) -> Result<(), StoreError> {
for digest in cases.blobs_of(case).await? {
match blobs.get(digest).await {
Ok(bytes) => {
drop(bytes);
report.blobs_present += 1;
}
Err(BlobError::Expired { .. }) => report.blobs_erased += 1,
Err(BlobError::NotFound(_)) => report.findings.push(format!(
"case {case}, blob {digest}: the bytes are gone with no tombstone — \
unexplained loss, which is a different fact from erasure and cannot be \
settled from the journal, because the journal deliberately never held \
the bytes"
)),
Err(e @ BlobError::Corrupt { .. }) => report.findings.push(format!(
"case {case}, blob {digest}: {e} — content that cannot be trusted is \
worse than content that is missing, because it is used"
)),
Err(e @ BlobError::UnreadableTombstone { .. }) => report.findings.push(format!(
"case {case}, blob {digest}: {e} — the bytes are gone and nothing here \
can say whether that was retention doing its job"
)),
Err(e @ BlobError::Unopened { .. }) => report.findings.push(format!(
"case {case}, blob {digest}: {e} — no erasure record accounts for it, so \
this plane cannot read a blob it is holding"
)),
Err(BlobError::Backend(e)) => report.not_checked.push(format!(
"case {case}, blob {digest}: the blob store could not be reached ({e}) — \
presence was not established either way"
)),
}
}
Ok(())
}
#[cfg(feature = "keyring")]
async fn check_sealed(
report: &mut DrillReport,
keys: &dyn crate::keyring::KeyRing,
case: crate::core::CaseId,
state: &serde_json::Value,
) {
use crate::keyring::KeyError;
match crate::keyring::probe_sealed_case_state(keys, case, state).await {
None => {}
Some(Ok(())) => report.sealed_open += 1,
Some(Err(KeyError::Destroyed { .. })) => report.sealed_erased += 1,
Some(Err(KeyError::Unavailable(e))) => report.not_checked.push(format!(
"case {case}: the key ring could not be reached ({e}) — whether the sealed \
state opens was not established either way"
)),
Some(Err(e @ KeyError::Retired { .. })) => report.findings.push(format!(
"case {case}: {e}. No erasure record accounts for this, so it is an erasure \
nobody requested — lower the floor to make the case readable again, or erase \
the case properly if that is what was intended"
)),
Some(Err(e @ KeyError::UnknownFormat { .. })) => report.findings.push(format!(
"case {case}: {e}. Run the plane on a build that reads this version, or restore \
this case from an export written by one — nothing here needs a key operation"
)),
Some(Err(e @ KeyError::UnreadableHeader { .. })) => report.findings.push(format!(
"case {case}: {e}. Establish which before acting: if another build has written \
this store, run that build; if none has, these bytes are damaged"
)),
Some(Err(e)) => report.findings.push(format!(
"case {case}: sealed state neither opens nor was its key destroyed ({e}) — \
an erasure would have said so, which makes this loss or tampering"
)),
}
}
#[cfg(all(test, feature = "keyring", feature = "testkit"))]
mod sealed_classification_tests {
use super::*;
use crate::core::CaseId;
use crate::testkit::MemoryKeyRing;
fn blank() -> DrillReport {
DrillReport {
cases: 0,
blobs_present: 0,
blobs_erased: 0,
sealed_open: 0,
sealed_erased: 0,
findings: Vec::new(),
not_checked: Vec::new(),
}
}
fn from_the_future() -> serde_json::Value {
crate::journal::payload::wrap(&[
crate::keyring::ENVELOPE_FORMAT_VERSION.wrapping_add(1),
0,
0,
0,
0,
])
}
#[tokio::test]
async fn a_version_this_build_cannot_read_is_not_reported_as_loss_or_tampering() {
let ring = MemoryKeyRing::new();
let mut report = blank();
check_sealed(&mut report, &ring, CaseId::generate(), &from_the_future()).await;
assert_eq!(
report.sealed_open, 0,
"state that never opened must not be counted as open"
);
assert_eq!(report.sealed_erased, 0, "nothing was erased — no key moved");
assert_eq!(report.findings.len(), 1, "{:#?}", report.findings);
let finding = &report.findings[0];
assert!(
!finding.contains("loss or tampering"),
"a build skew reported in the vocabulary of an incident: {finding}"
);
assert!(
finding.contains("format version") && finding.contains("build"),
"the finding must carry its own remedy: {finding}"
);
}
#[tokio::test]
async fn damage_at_this_builds_own_version_is_still_loss_or_tampering() {
let ring = MemoryKeyRing::new();
let truncated =
crate::journal::payload::wrap(&[crate::keyring::ENVELOPE_FORMAT_VERSION, 0]);
let mut report = blank();
check_sealed(&mut report, &ring, CaseId::generate(), &truncated).await;
assert_eq!(report.findings.len(), 1, "{:#?}", report.findings);
assert!(
report.findings[0].contains("loss or tampering"),
"damage inside a version this build reads must still page somebody: {}",
report.findings[0]
);
}
#[tokio::test]
async fn a_header_this_build_cannot_parse_names_both_causes() {
let ring = MemoryKeyRing::new();
let header = br#"{"scope":"acme/matter","kdf":"argon2id"}"#;
let mut bytes = vec![crate::keyring::ENVELOPE_FORMAT_VERSION];
bytes.extend_from_slice(&u32::try_from(header.len()).expect("fits").to_be_bytes());
bytes.extend_from_slice(header);
bytes.extend_from_slice(&[0_u8; 24]);
bytes.extend_from_slice(b"ciphertext");
let mut report = blank();
check_sealed(
&mut report,
&ring,
CaseId::generate(),
&crate::journal::payload::wrap(&bytes),
)
.await;
assert_eq!(report.findings.len(), 1, "{:#?}", report.findings);
let finding = &report.findings[0];
assert!(
finding.contains("another build wrote them"),
"the benign cause has to be offered, or this pages somebody for a rollback: \
{finding}"
);
assert!(
finding.contains("Establish which before acting"),
"the finding has to hand over the step that separates the two causes: {finding}"
);
assert!(
!finding.contains("an erasure would have said so"),
"a cause this build cannot establish, reported as an incident: {finding}"
);
}
#[tokio::test]
async fn unsealed_state_is_neither_counted_nor_reported() {
let ring = MemoryKeyRing::new();
let mut report = blank();
check_sealed(
&mut report,
&ring,
CaseId::generate(),
&serde_json::json!({ "about": "a readable matter" }),
)
.await;
assert_eq!(
report,
blank(),
"unsealed state moved a counter: {report:#?}"
);
}
}