diff --git a/docs/spec.md b/docs/spec.md index 95795e3..345d5e3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -477,7 +477,13 @@ protocol; explicit full verification remains available to check local storage. observed storage head as valid, invalid, unverifiable, unsupported, unavailable, or discontinuous. Recovery publication is permitted only for an invalid head. Unverifiable, unsupported, unavailable, and discontinuous heads MUST never be -overridden. In particular, a device that cannot open the current envelope +overridden. A head is invalid only when its content is positively proven bad: +a content-ID, decryption, key-commitment, or signature failure, malformed +bytes, or a failed semantic validation. Any other failure MUST be classified +unverifiable. A manifest or policy whose format version, authorization +variant, or administrator threshold is unknown is unsupported, and the version +MUST be read before the full schema so a future schema is not mistaken for +malformed bytes. In particular, a device that cannot open the current envelope MUST NOT recover from an older floor, because the head may be a valid revocation. A signer ID extracted before successful signature verification MUST be labelled as a claim, not as the signer. @@ -494,7 +500,11 @@ content ID matches, so a corrupt copy cannot hide an older valid one. Bytes read from rejected copies count against the byte budget. Discovery MUST report authenticated descendants and replays. If authenticated descendants conflict, recovery MUST refuse to choose a fork. A replayed older -manifest MUST NOT be treated as a newer candidate. Directory storage has no +manifest MUST NOT be treated as a newer candidate. A historical head in the +floor's authenticated chain is a replay and MUST be recognised without opening +it, because a device added after genesis cannot open older generations. Any +other historical head that is not proven invalid and is not an authenticated +descendant of the floor MUST block publication. Directory storage has no outer history and MUST offer only the pinned floor. The default base MUST be the newest validated descendant of the floor, or the diff --git a/src/error.rs b/src/error.rs index d59670c..2f69109 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,3 +41,11 @@ pub struct ForeignRepository; #[derive(Debug, Error)] #[error("{0}")] pub struct BudgetExhausted(pub String); + +/// Authenticated content is proven bad: it fails decryption, a signature, +/// a key commitment, or a content ID, or it is malformed or semantically +/// invalid. Recovery classifies a head as invalid only through this type; +/// every unrecognized failure blocks recovery. +#[derive(Debug, Error)] +#[error("authenticated repository state is invalid")] +pub struct InvalidState; diff --git a/src/manifest.rs b/src/manifest.rs index 9a01ae6..08edf48 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -328,7 +328,55 @@ pub fn seal_manifest( })?) } +/// Checks the format version before the full schema, so a future format is +/// reported as unsupported instead of malformed. +fn require_supported_format(encrypted: &[u8]) -> Result<()> { + #[derive(Deserialize)] + struct EnvelopeProbe { + header: String, + } + #[derive(Deserialize)] + struct HeaderProbe { + format_version: u64, + } + let envelope: EnvelopeProbe = + serde_json::from_slice(encrypted).context("parse manifest envelope")?; + let header = BASE64 + .decode(envelope.header) + .context("decode manifest header")?; + let probe: HeaderProbe = + serde_json::from_slice(&header).context("parse manifest format version")?; + if probe.format_version != u64::from(FORMAT_VERSION) { + bail!(Unsupported(format!( + "unsupported manifest format {}", + probe.format_version + ))) + } + Ok(()) +} + +/// Checks the transition discriminator before the full sealed-header schema. +fn require_supported_authorization(exact_sealed_content: &[u8]) -> Result<()> { + #[derive(Deserialize)] + struct AuthorizationProbe { + authorization: String, + } + let probe: AuthorizationProbe = serde_json::from_slice(exact_sealed_content) + .context("parse sealed manifest authorization")?; + let known = serde_json::from_value::(serde_json::Value::String( + probe.authorization.clone(), + )); + if known.is_err() { + bail!(Unsupported(format!( + "unsupported manifest authorization {:?}", + probe.authorization + ))) + } + Ok(()) +} + pub fn peek_manifest_header(encrypted: &[u8]) -> Result { + require_supported_format(encrypted)?; let envelope: ManifestEnvelope = serde_json::from_slice(encrypted).context("parse manifest envelope")?; let exact_header = BASE64 @@ -369,6 +417,7 @@ pub fn unwrap_generation_key(header: &ManifestHeader, key: &KeyFile) -> Result Result { + require_supported_format(encrypted)?; let envelope: ManifestEnvelope = serde_json::from_slice(encrypted).context("parse manifest envelope")?; let exact_header = BASE64 @@ -405,6 +454,7 @@ pub fn open_manifest(encrypted: &[u8], generation_key: &[u8; 32]) -> Result Vec { + rewrite_signed_sealed_content(encrypted, signer, generation_key, |header, exact| { + let mut content: SealedHeaderContent = serde_json::from_slice(exact).unwrap(); + edit(header, &mut content); + *exact = serde_json::to_vec(&content).unwrap(); + }) + } + + fn rewrite_signed_sealed_content( + encrypted: &[u8], + signer: &KeyFile, + generation_key: &[u8; 32], + edit: impl FnOnce(&mut ManifestHeader, &mut Vec), ) -> Vec { let mut envelope: ManifestEnvelope = serde_json::from_slice(encrypted).unwrap(); let mut header_bytes = BASE64.decode(&envelope.header).unwrap(); @@ -956,10 +1019,8 @@ mod tests { .unwrap(); let sealed_bytes = open_with_key(&sealed_key, &sealed_ciphertext, &header_bytes).unwrap(); let mut sealed: SealedHeaderEnvelope = serde_json::from_slice(&sealed_bytes).unwrap(); - let mut content: SealedHeaderContent = - serde_json::from_slice(&BASE64.decode(&sealed.content).unwrap()).unwrap(); - edit(&mut header, &mut content); - let exact_content = serde_json::to_vec(&content).unwrap(); + let mut exact_content = BASE64.decode(&sealed.content).unwrap(); + edit(&mut header, &mut exact_content); header.sealed_header_digest = hex::encode(Sha256::digest(&exact_content)); header_bytes = serde_json::to_vec(&header).unwrap(); let body_ciphertext = BASE64.decode(&envelope.body_ciphertext).unwrap(); @@ -1342,6 +1403,51 @@ mod tests { ); } + fn is_unsupported(result: Result) -> bool { + result + .unwrap_err() + .chain() + .any(|cause| cause.is::()) + } + + #[test] + fn a_future_header_schema_is_unsupported_not_malformed() { + let owner = KeyFile::generate(); + let (policy, _) = PolicyState::genesis(&owner, "owner").unwrap(); + let generation_key = random_key(); + let valid = writer_manifest(&owner, &policy, &generation_key); + let mut envelope: serde_json::Value = serde_json::from_slice(&valid).unwrap(); + let mut header: serde_json::Value = + serde_json::from_slice(&BASE64.decode(envelope["header"].as_str().unwrap()).unwrap()) + .unwrap(); + header["format_version"] = serde_json::json!(FORMAT_VERSION + 1); + header["admin_quorum"] = serde_json::json!(2); + header.as_object_mut().unwrap().remove("key_commitment"); + envelope["header"] = BASE64.encode(serde_json::to_vec(&header).unwrap()).into(); + envelope["future_part"] = "".into(); + let future = serde_json::to_vec(&envelope).unwrap(); + + assert!(is_unsupported(peek_manifest_header(&future))); + assert!(is_unsupported(open_manifest(&future, &generation_key))); + } + + #[test] + fn an_unknown_authorization_variant_is_unsupported_not_malformed() { + let owner = KeyFile::generate(); + let (policy, _) = PolicyState::genesis(&owner, "owner").unwrap(); + let generation_key = random_key(); + let valid = writer_manifest(&owner, &policy, &generation_key); + let future = rewrite_signed_sealed_content(&valid, &owner, &generation_key, |_, exact| { + let mut content: serde_json::Value = serde_json::from_slice(exact).unwrap(); + content["authorization"] = "quorum_transition".into(); + content["quorum_signatures"] = serde_json::json!([]); + *exact = serde_json::to_vec(&content).unwrap(); + }); + + assert!(peek_manifest_header(&future).is_ok()); + assert!(is_unsupported(open_manifest(&future, &generation_key))); + } + #[test] fn generation_overflow_is_a_validation_error() { let owner = KeyFile::generate(); diff --git a/src/policy.rs b/src/policy.rs index ca4929b..c5b2293 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -161,15 +161,36 @@ impl PolicyState { } pub fn parse(bytes: &[u8]) -> Result { - let object: PolicyObject = serde_json::from_slice(bytes).context("parse policy object")?; - let exact_body = BASE64.decode(object.body).context("decode policy body")?; - let body: PolicyBody = serde_json::from_slice(&exact_body).context("parse policy body")?; - if body.format_version != POLICY_FORMAT_VERSION { + #[derive(Deserialize)] + struct ObjectProbe { + body: String, + } + #[derive(Deserialize)] + struct BodyProbe { + format_version: u64, + } + // Probe the version before the full schema, so a future format is + // reported as unsupported instead of malformed. + let probe: ObjectProbe = serde_json::from_slice(bytes).context("parse policy object")?; + let probe_body = BASE64.decode(probe.body).context("decode policy body")?; + let version: BodyProbe = + serde_json::from_slice(&probe_body).context("parse policy format version")?; + if version.format_version != u64::from(POLICY_FORMAT_VERSION) { bail!(Unsupported(format!( "unsupported policy format {}", - body.format_version + version.format_version ))) } + let object: PolicyObject = serde_json::from_slice(bytes).context("parse policy object")?; + let exact_body = BASE64.decode(object.body).context("decode policy body")?; + let body: PolicyBody = serde_json::from_slice(&exact_body).context("parse policy body")?; + match body.admin_threshold { + 0 => bail!("policy administrator threshold must be at least one"), + 1 => {} + threshold => bail!(Unsupported(format!( + "unsupported policy administrator threshold {threshold}" + ))), + } validate_devices(&body.devices)?; validate_device_roots(&body)?; validate_active_roles(&body)?; @@ -536,6 +557,45 @@ mod tests { .unwrap() } + fn is_unsupported(result: Result) -> bool { + result + .unwrap_err() + .chain() + .any(|cause| cause.is::()) + } + + #[test] + fn a_future_policy_schema_is_unsupported_not_malformed() { + let owner = KeyFile::generate(); + let (genesis, _) = PolicyState::genesis(&owner, "owner").unwrap(); + let mut body = serde_json::to_value(&genesis.body).unwrap(); + body["format_version"] = serde_json::json!(POLICY_FORMAT_VERSION + 1); + body["admin_quorum"] = serde_json::json!({"threshold": 2}); + body.as_object_mut().unwrap().remove("admin_threshold"); + let future = serde_json::to_vec(&serde_json::json!({ + "body": BASE64.encode(serde_json::to_vec(&body).unwrap()), + "signatures": [], + "quorum_certificate": null + })) + .unwrap(); + assert!(is_unsupported(PolicyState::parse(&future))); + } + + #[test] + fn a_multi_admin_threshold_is_unsupported_and_zero_is_invalid() { + let owner = KeyFile::generate(); + let (genesis, _) = PolicyState::genesis(&owner, "owner").unwrap(); + let mut body = genesis.body.clone(); + body.admin_threshold = 2; + assert!(is_unsupported(PolicyState::parse(&sign_unchecked( + &body, &owner + )))); + body.admin_threshold = 0; + assert!(!is_unsupported(PolicyState::parse(&sign_unchecked( + &body, &owner + )))); + } + #[test] fn received_genesis_with_an_invalid_owner_label_is_rejected() { let owner = KeyFile::generate(); diff --git a/src/repository.rs b/src/repository.rs index 1f4d7d4..a0e5985 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -15,13 +15,13 @@ use crate::crypto::{ random_key, seal_pack_stream, }; use crate::error::{ - BudgetExhausted, ForeignRepository, MissingStorageHead, NotActiveReader, ObjectUnavailable, - Unsupported, + BudgetExhausted, ForeignRepository, InvalidState, MissingStorageHead, NotActiveReader, + ObjectUnavailable, Unsupported, }; use crate::git; use crate::manifest::{ - Manifest, ManifestAuthorization, ManifestHeader, PackDescriptor, open_manifest, pack_aad, - peek_manifest_header, seal_manifest, unwrap_generation_key, unwrap_predecessor_key, + Manifest, ManifestAuthorization, ManifestHeader, OpenedManifest, PackDescriptor, open_manifest, + pack_aad, peek_manifest_header, seal_manifest, unwrap_generation_key, unwrap_predecessor_key, verify_manifest, wrap_predecessor_key, }; use crate::policy::{DeviceRecord, DeviceRoles, PolicyState}; @@ -966,9 +966,9 @@ impl EncryptedRepository { &mut |id| self.read_manifest_material(id), Some(&mut budget), ), - None if observation.head_bytes.is_some() => Err(anyhow::anyhow!( + None if observation.head_bytes.is_some() => Err(proven_invalid(anyhow::anyhow!( "storage HEAD is not a valid manifest object ID" - )), + ))), None => Err(MissingStorageHead.into()), }; match current_chain { @@ -1126,9 +1126,11 @@ impl EncryptedRepository { ))); }; if object_id(bytes) != id { - bail!("historical manifest {id} content ID mismatch") + return Err(proven_invalid(anyhow::anyhow!( + "historical manifest {id} content ID mismatch" + ))); } - let header = peek_manifest_header(bytes)?; + let header = peek_manifest_header(bytes).map_err(proven_invalid)?; Ok((id.to_owned(), bytes.clone(), header)) } } @@ -1139,6 +1141,9 @@ impl EncryptedRepository { Some(&mut budget), ) { Ok(chain) => chain, + Err(error) if error.is::() => { + return Err(error).context("bounded recovery discovery failed"); + } Err(error) => { report.blocked_reason = Some(format!( "pinned floor is unavailable or cannot be authenticated: {error:#}" @@ -1206,37 +1211,35 @@ impl EncryptedRepository { if candidate_id == floor_id { continue; } + // Membership needs no key: a device added after genesis cannot + // open the heads that predate it, but they are in its floor chain. + if floor_chain.iter().any(|entry| entry.id == *candidate_id) { + replay_ids.insert(candidate_id.clone()); + continue; + } let candidate_chain = match self.chain_from( candidate_id, &mut |id| read_history_material(id), Some(&mut budget), ) { Ok(chain) => chain, - Err(error) => { - let (class, _) = classify_recovery_error(&error); - match class { - RecoveryClass::Invalid => continue, - RecoveryClass::Unverifiable - | RecoveryClass::Unsupported - | RecoveryClass::Unavailable => { - report.blocked_reason = Some(format!( - "historical carrier head {candidate_id} is {}: {error:#}", - class.name() - )); - return Ok(report); - } - RecoveryClass::Discontinuous | RecoveryClass::Valid => continue, - } + Err(error) if error.is::() => { + return Err(error).context("bounded recovery discovery failed"); } + Err(error) => match classify_recovery_error(&error).0 { + RecoveryClass::Invalid => continue, + class => { + report.blocked_reason = Some(format!( + "historical carrier head {candidate_id} is {}: {error:#}", + class.name() + )); + return Ok(report); + } + }, }; let candidate = candidate_chain .first() .context("validated candidate chain is empty")?; - let candidate_in_floor = floor_chain.iter().any(|entry| entry.id == *candidate_id); - if candidate.manifest.generation < floor_generation && candidate_in_floor { - replay_ids.insert(candidate_id.clone()); - continue; - } let floor_position = candidate_chain.iter().position(|entry| { entry.id == floor_id && entry.manifest.generation == floor_generation }); @@ -1522,17 +1525,21 @@ impl EncryptedRepository { budget.charge_manifest(&header, encrypted.len())?; } let mut generation_key = unwrap_generation_key(&header, &self.key)?; - let mut opened = open_manifest(&encrypted, &generation_key)?; + let mut opened = open_manifest(&encrypted, &generation_key).map_err(proven_invalid)?; let mut unverified = Vec::new(); let mut seen = HashSet::new(); loop { if !seen.insert(id.clone()) { - bail!("manifest chain contains a cycle") + return Err(proven_invalid(anyhow::anyhow!( + "manifest chain contains a cycle" + ))); } let Some(previous_id) = opened.manifest.previous.clone() else { if opened.manifest.generation != 0 { - bail!("manifest chain terminated above generation zero") + return Err(proven_invalid(anyhow::anyhow!( + "manifest chain terminated above generation zero" + ))); } unverified.push((id, opened, generation_key)); break; @@ -1542,18 +1549,29 @@ impl EncryptedRepository { budget.charge_manifest(&parent_header, parent_encrypted.len())?; } let parent_key = - unwrap_predecessor_key(&generation_key, &opened.manifest, &parent_header)?; + unwrap_predecessor_key(&generation_key, &opened.manifest, &parent_header) + .map_err(proven_invalid)?; unverified.push((id, opened, generation_key)); id = parent_id; encrypted = parent_encrypted; header = parent_header; generation_key = parent_key; - opened = open_manifest(&encrypted, &generation_key)?; + opened = open_manifest(&encrypted, &generation_key).map_err(proven_invalid)?; if opened.header != header { - bail!("predecessor manifest header changed while opening") + return Err(proven_invalid(anyhow::anyhow!( + "predecessor manifest header changed while opening" + ))); } } + self.verify_opened_chain(unverified).map_err(proven_invalid) + } + /// Authenticates opened manifests oldest first against their policies. + /// Every failure here is a property of authenticated bytes. + fn verify_opened_chain( + &self, + unverified: Vec<(String, OpenedManifest, SecretKey)>, + ) -> Result> { let mut chain_oldest_first: Vec = Vec::with_capacity(unverified.len()); let mut seen_pack_ids = HashSet::new(); for (index, (id, opened, generation_key)) in unverified.into_iter().rev().enumerate() { @@ -1619,9 +1637,11 @@ impl EncryptedRepository { fn read_manifest_material(&self, id: &str) -> Result<(String, Vec, ManifestHeader)> { let bytes = self.storage.get_object(ObjectKind::Manifest, id)?; if object_id(&bytes) != id { - bail!("manifest ciphertext hash mismatch") + return Err(proven_invalid(anyhow::anyhow!( + "manifest ciphertext hash mismatch" + ))); } - let header = peek_manifest_header(&bytes)?; + let header = peek_manifest_header(&bytes).map_err(proven_invalid)?; Ok((id.to_owned(), bytes, header)) } @@ -1762,12 +1782,24 @@ fn classify_recovery_error(error: &anyhow::Error) -> (RecoveryClass, String) { .is_some_and(|source| source.kind() != io::ErrorKind::InvalidData) }) { RecoveryClass::Unavailable - } else { + } else if error.downcast_ref::().is_some() { RecoveryClass::Invalid + } else { + RecoveryClass::Unverifiable }; (class, format!("{error:#}")) } +/// Marks a failure of authenticated content as proven invalid, unless the +/// content is merely in a format this implementation does not support. +fn proven_invalid(error: anyhow::Error) -> anyhow::Error { + if error.chain().any(|cause| cause.is::()) { + error + } else { + error.context(InvalidState) + } +} + fn record_rejected_head( state_path: &Path, head_id: &str, @@ -2034,8 +2066,8 @@ mod tests { use std::io; use super::{ - ClientState, ForeignRepository, MissingStorageHead, NotActiveReader, ObjectUnavailable, - RecoveryClass, Unsupported, classify_recovery_error, read_client_state, + ClientState, ForeignRepository, InvalidState, MissingStorageHead, NotActiveReader, + ObjectUnavailable, RecoveryClass, Unsupported, classify_recovery_error, read_client_state, testing_write_client_state, }; @@ -2149,15 +2181,16 @@ mod tests { "carrier object 00 is unavailable", "encrypted repository has no storage HEAD", "recovery manifest byte budget exhausted", + "authenticated repository state is invalid", ] { let (class, _) = classify_recovery_error(&anyhow::anyhow!(text.to_owned())); - assert_eq!(class, RecoveryClass::Invalid, "{text}"); + assert_eq!(class, RecoveryClass::Unverifiable, "{text}"); } } #[test] fn recovery_classification_follows_error_types_through_context() { - let cases: [(anyhow::Error, RecoveryClass); 6] = [ + let cases: [(anyhow::Error, RecoveryClass); 9] = [ ( Unsupported("manifest format 99".to_owned()).into(), RecoveryClass::Unsupported, @@ -2176,8 +2209,17 @@ mod tests { (MissingStorageHead.into(), RecoveryClass::Unavailable), ( io::Error::from(io::ErrorKind::InvalidData).into(), + RecoveryClass::Unverifiable, + ), + (InvalidState.into(), RecoveryClass::Invalid), + ( + anyhow::anyhow!("manifest body digest mismatch").context(InvalidState), RecoveryClass::Invalid, ), + ( + anyhow::Error::from(NotActiveReader).context(InvalidState), + RecoveryClass::Unverifiable, + ), ]; for (error, expected) in cases { let error = error.context("read manifest"); diff --git a/tests/carrier_git.rs b/tests/carrier_git.rs index c79eafb..8305f86 100644 --- a/tests/carrier_git.rs +++ b/tests/carrier_git.rs @@ -2230,3 +2230,95 @@ fn carrier_history_reads_skip_corrupt_copies_for_an_older_valid_one() { .unwrap() ); } + +/// The writer joins after genesis, so the heads that predate it are sealed +/// to readers it never was. Recovery from a malformed HEAD must still work. +fn recover_junk_head_after_later_writer_push(recover_as_owner: bool) { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + let owner_checkout = temporary.path().join("owner"); + let writer_checkout = temporary.path().join("writer"); + init_source(&owner_checkout, "owner content"); + init_source(&writer_checkout, "writer content"); + + let owner = KeyFile::generate(); + let writer = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + init_carrier(temporary.path(), &carrier, &owner); + let open = |key: &KeyFile| { + EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + key.clone(), + ) + }; + open(&owner).pin_admin_state(&admin_pin).unwrap(); + open(&owner) + .add_device( + writer.public_device().unwrap(), + "writer", + DeviceRoles::collaborator(), + &admin_pin, + ) + .unwrap(); + open(&writer) + .push_update_for_remote( + &writer_checkout, + "origin", + "refs/heads/main", + "refs/heads/main", + false, + ) + .unwrap(); + open(&owner).fetch_into(&owner_checkout, "origin").unwrap(); + let (floor_id, _) = open(&owner).current_manifest().unwrap(); + + edit_carrier( + &carrier, + &temporary.path().join("carrier-edit"), + |workdir| { + fs::write(workdir.join("HEAD"), "junk\n").unwrap(); + }, + ); + + let (key, checkout) = if recover_as_owner { + (&owner, &owner_checkout) + } else { + (&writer, &writer_checkout) + }; + let recover = |publish| { + open(key) + .recover( + checkout, + RecoveryOptions { + publish, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap() + }; + let report = recover(false); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert_eq!(report.blocked_reason, None); + assert_eq!(report.default_base.as_deref(), Some(floor_id.as_str())); + assert_eq!(report.replays.len(), 2, "{:?}", report.replays); + + let published = recover(true); + assert_eq!(published.blocked_reason, None); + let recovered_id = published.published_manifest.unwrap(); + let (head, manifest) = open(key).current_manifest().unwrap(); + assert_eq!(head, recovered_id); + assert_eq!(manifest.previous.as_deref(), Some(floor_id.as_str())); +} + +#[test] +fn carrier_recovery_works_for_the_owner() { + recover_junk_head_after_later_writer_push(true); +} + +#[test] +fn carrier_recovery_works_for_a_writer_added_after_genesis() { + recover_junk_head_after_later_writer_push(false); +} diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 406aa56..fc8ad18 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::fs; use std::io::Read; use std::path::Path; @@ -66,6 +67,221 @@ impl Storage for OverBudgetHistoryStorage { } } +/// Serves a fixed carrier-like outer history over directory storage. +#[derive(Clone)] +struct FixedHistoryStorage { + inner: FilesystemStorage, + heads: Vec, + manifests: BTreeMap>, +} + +impl Storage for FixedHistoryStorage { + fn begin_object(&self, kind: ObjectKind) -> Result> { + self.inner.begin_object(kind) + } + + fn open_object(&self, kind: ObjectKind, id: &str) -> Result> { + self.inner.open_object(kind, id) + } + + fn read_head(&self) -> Result> { + self.inner.read_head() + } + + fn compare_and_swap_head(&self, expected: Option<&str>, next: &str) -> Result<()> { + self.inner.compare_and_swap_head(expected, next) + } + + fn recovery_history( + &self, + _max_commits: usize, + _max_manifest_bytes: u64, + ) -> Result> { + Ok(Some(RecoveryHistory { + commits: self + .heads + .iter() + .enumerate() + .map(|(index, head)| RecoveryCommit { + commit_id: format!("commit-{index}"), + head_id: Some(head.clone()), + }) + .collect(), + manifests: self.manifests.clone(), + })) + } +} + +/// A repository whose floor is genesis and whose storage head is malformed. +fn floor_with_malformed_head( + temporary: &Path, +) -> ( + KeyFile, + FilesystemStorage, + std::path::PathBuf, + String, + String, +) { + let repo = temporary.join("repo"); + initialize_git(&repo); + let key = KeyFile::generate(); + let storage = FilesystemStorage::new(temporary.join("storage")); + let repository = EncryptedRepository::new(storage.clone(), key.clone()); + repository.initialize("owner").unwrap(); + repository.fetch_into(&repo, "encrypted").unwrap(); + let floor = storage.read_head().unwrap().unwrap(); + let junk = b"malformed successor"; + let junk_id = object_id(junk); + storage + .put_object_if_absent(ObjectKind::Manifest, &junk_id, junk) + .unwrap(); + storage + .compare_and_swap_head(Some(&floor), &junk_id) + .unwrap(); + (key, storage, repo, floor, junk_id) +} + +fn manifest_with_header(header: serde_json::Value) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "header": base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&header).unwrap()), + "sealed_header_ciphertext": "", + "body_ciphertext": "" + })) + .unwrap() +} + +fn publish_recovery( + repository: &EncryptedRepository, + repo: &Path, +) -> Result { + repository.recover( + repo, + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) +} + +#[test] +fn recovery_budget_exhaustion_inside_candidate_discovery_is_fatal() { + let temporary = tempfile::tempdir().unwrap(); + let (key, storage, repo, floor, junk_id) = floor_with_malformed_head(temporary.path()); + let oversized = manifest_with_header(serde_json::json!({ + "format_version": 5, + "repository_root": key.repository_root, + "generation": 1, + "previous": floor, + "key_commitment": "0".repeat(64), + "generation_key_envelopes": [ + {"encapsulated_key": "", "ciphertext": "A".repeat(36 * 1024 * 1024)}, + {"encapsulated_key": "", "ciphertext": ""}, + {"encapsulated_key": "", "ciphertext": ""}, + {"encapsulated_key": "", "ciphertext": ""} + ], + "body_digest": "0".repeat(64), + "sealed_header_digest": "0".repeat(64) + })); + let oversized_id = object_id(&oversized); + let recovery = EncryptedRepository::new( + FixedHistoryStorage { + inner: storage.clone(), + heads: vec![junk_id.clone(), oversized_id.clone(), floor.clone()], + manifests: BTreeMap::from([(oversized_id, oversized)]), + }, + key, + ); + + let error = publish_recovery(&recovery, &repo).unwrap_err(); + assert!( + format!("{error:#}").contains("byte budget exhausted"), + "{error:#}" + ); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(junk_id.as_str()) + ); + assert_eq!(client_state(&repo)["head_id"], floor); +} + +#[test] +fn recovery_refuses_to_skip_a_discontinuous_historical_head() { + let temporary = tempfile::tempdir().unwrap(); + let (key, storage, repo, floor, junk_id) = floor_with_malformed_head(temporary.path()); + let foreign_storage = FilesystemStorage::new(temporary.path().join("foreign")); + EncryptedRepository::new(foreign_storage.clone(), KeyFile::generate()) + .initialize("foreign") + .unwrap(); + let foreign_id = foreign_storage.read_head().unwrap().unwrap(); + let foreign = foreign_storage + .get_object(ObjectKind::Manifest, &foreign_id) + .unwrap(); + let recovery = EncryptedRepository::new( + FixedHistoryStorage { + inner: storage.clone(), + heads: vec![junk_id.clone(), foreign_id.clone(), floor.clone()], + manifests: BTreeMap::from([(foreign_id.clone(), foreign)]), + }, + key, + ); + + let report = publish_recovery(&recovery, &repo).unwrap(); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert!(report.published_manifest.is_none()); + let blocked = report.blocked_reason.unwrap(); + assert!( + blocked.contains(&format!( + "historical carrier head {foreign_id} is discontinuous" + )), + "{blocked}" + ); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(junk_id.as_str()) + ); + assert_eq!(client_state(&repo)["head_id"], floor); +} + +#[test] +fn recover_refuses_a_head_with_a_future_manifest_schema() { + let temporary = tempfile::tempdir().unwrap(); + let (key, storage, repo, floor, junk_id) = floor_with_malformed_head(temporary.path()); + let future = manifest_with_header(serde_json::json!({ + "format_version": 6, + "repository_root": key.repository_root, + "generation": 1, + "previous": floor, + "key_commitments": ["0".repeat(64)], + "admin_quorum": 2, + "generation_key_envelopes": [], + "body_digest": "0".repeat(64) + })); + let future_id = object_id(&future); + storage + .put_object_if_absent(ObjectKind::Manifest, &future_id, &future) + .unwrap(); + storage + .compare_and_swap_head(Some(&junk_id), &future_id) + .unwrap(); + + let report = publish_recovery(&EncryptedRepository::new(storage.clone(), key), &repo).unwrap(); + assert_eq!( + report.classification, + RecoveryClass::Unsupported, + "{}", + report.reason + ); + assert!(report.published_manifest.is_none()); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(future_id.as_str()) + ); + assert_eq!(client_state(&repo)["head_id"], floor); +} + impl Storage for BarrierStorage { fn begin_object(&self, kind: ObjectKind) -> Result> { self.inner.begin_object(kind)