diff --git a/src/crypto.rs b/src/crypto.rs index 3beb615..799e7eb 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -24,6 +24,7 @@ use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use crate::error::Unsupported; use crate::trace; const SYMMETRIC_MAGIC: &[u8; 8] = b"E2EES05\0"; @@ -122,7 +123,10 @@ impl KeyFile { let bytes = fs::read(path).with_context(|| format!("read key file {}", path.display()))?; let key: Self = serde_json::from_slice(&bytes).context("parse key file")?; if key.format_version != 3 { - bail!("unsupported key file format {}", key.format_version) + bail!(Unsupported(format!( + "unsupported key file format {}", + key.format_version + ))) } validate_root(&key.repository_root)?; key.signing_key()?; @@ -562,7 +566,7 @@ pub fn open_pack_stream( .expect("fixed chunk size field"), ) as usize; if chunk_size != PACK_STREAM_CHUNK_SIZE { - bail!("unsupported pack stream chunk size") + bail!(Unsupported("unsupported pack stream chunk size".to_owned())) } let segments = plaintext_size.div_ceil(PACK_STREAM_CHUNK_SIZE as u64); if segments == 0 || segments >= u32::MAX as u64 { diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d59670c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,43 @@ +//! Failures that recovery classifies. Recovery inspects these by type, never +//! by message text, because messages can carry storage-controlled strings. + +use std::io; + +use thiserror::Error; + +/// A required storage object is missing or could not be read. +#[derive(Debug, Error)] +#[error("{storage} object {id} is unavailable")] +pub struct ObjectUnavailable { + pub storage: &'static str, + pub id: String, + #[source] + pub source: Option, +} + +/// Storage holds no HEAD at all. +#[derive(Debug, Error)] +#[error("encrypted repository has no storage HEAD")] +pub struct MissingStorageHead; + +/// A format version or transition this implementation does not support. +#[derive(Debug, Error)] +#[error("{0}")] +pub struct Unsupported(pub String); + +/// No generation-key envelope opens for this device. +#[derive(Debug, Error)] +#[error( + "device is not an active reader of the current generation (revoked, never added, or envelope malformed)" +)] +pub struct NotActiveReader; + +/// The local key file and the manifest name different repository roots. +#[derive(Debug, Error)] +#[error("key file belongs to a different repository")] +pub struct ForeignRepository; + +/// A fixed recovery budget ran out; recovery fails closed. +#[derive(Debug, Error)] +#[error("{0}")] +pub struct BudgetExhausted(pub String); diff --git a/src/lib.rs b/src/lib.rs index 083af90..2cc2ffd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod crypto; +pub mod error; pub mod git; pub mod manifest; mod persist; diff --git a/src/manifest.rs b/src/manifest.rs index 7a015ce..c3fac7f 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -11,6 +11,7 @@ use crate::crypto::{ derive_subkey, dummy_generation_envelope, open_with_key, random_seed, seal_with_key, verify_domain, verify_key_commitment, wrap_generation_key_with_seed, }; +use crate::error::{ForeignRepository, NotActiveReader, Unsupported}; use crate::policy::PolicyState; pub const FORMAT_VERSION: u32 = 5; @@ -143,7 +144,9 @@ impl Manifest { } } ManifestAuthorization::Checkpoint => { - bail!("checkpoint transitions are reserved but not implemented") + bail!(Unsupported( + "checkpoint transitions are reserved but not implemented".to_owned() + )) } } Ok(()) @@ -248,7 +251,9 @@ pub fn seal_manifest( validate_introduced_policy(&manifest, policy, true)? } ManifestAuthorization::Checkpoint => { - bail!("checkpoint transitions are reserved but not implemented") + bail!(Unsupported( + "checkpoint transitions are reserved but not implemented".to_owned() + )) } } validate_pack_delta(&manifest)?; @@ -337,7 +342,7 @@ pub fn peek_manifest_header(encrypted: &[u8]) -> Result { pub fn unwrap_generation_key(header: &ManifestHeader, key: &KeyFile) -> Result { if key.repository_root != header.repository_root { - bail!("key file belongs to a different repository") + bail!(ForeignRepository) } validate_public_header(header)?; let device_id = key.device_id()?; @@ -360,9 +365,7 @@ pub fn unwrap_generation_key(header: &ManifestHeader, key: &KeyFile) -> Result Result { @@ -508,7 +511,9 @@ pub fn verify_manifest( .context("policy transition manifest was not signed by a parent administrator")? } ManifestAuthorization::Checkpoint => { - bail!("checkpoint transitions are reserved but not implemented") + bail!(Unsupported( + "checkpoint transitions are reserved but not implemented".to_owned() + )) } }; verify_domain( @@ -764,7 +769,10 @@ fn validate_introduced_policy( fn validate_public_header(header: &ManifestHeader) -> Result<()> { if header.format_version != FORMAT_VERSION { - bail!("unsupported manifest format {}", header.format_version) + bail!(Unsupported(format!( + "unsupported manifest format {}", + header.format_version + ))) } if header.repository_root.len() != 64 || !header diff --git a/src/policy.rs b/src/policy.rs index 7d90403..7d214c2 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -9,6 +9,7 @@ use crate::crypto::{ KeyFile, PublicDevice, object_id, repository_root_for_device, validate_public_device, verify_domain, }; +use crate::error::Unsupported; pub const POLICY_FORMAT_VERSION: u32 = 6; const POLICY_SIGNATURE_DOMAIN: &[u8] = b"git-remote-e2ee policy v6\0"; @@ -160,7 +161,10 @@ impl PolicyState { 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 { - bail!("unsupported policy format {}", body.format_version) + bail!(Unsupported(format!( + "unsupported policy format {}", + body.format_version + ))) } validate_devices(&body.devices)?; validate_device_roots(&body)?; diff --git a/src/repository.rs b/src/repository.rs index e50d751..7f040be 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -14,6 +14,10 @@ use crate::crypto::{ KeyFile, PublicDevice, SecretKey, SubkeyKind, derive_subkey, object_id, open_pack_stream, random_key, seal_pack_stream, }; +use crate::error::{ + BudgetExhausted, ForeignRepository, MissingStorageHead, NotActiveReader, ObjectUnavailable, + Unsupported, +}; use crate::git; use crate::manifest::{ Manifest, ManifestAuthorization, ManifestHeader, PackDescriptor, open_manifest, pack_aad, @@ -163,7 +167,9 @@ impl RecoveryBudget { .checked_add(byte_count as u64) .context("recovery byte budget overflow")?; if self.bytes_read > MAX_RECOVERY_BYTES { - bail!("recovery manifest byte budget exhausted") + bail!(BudgetExhausted( + "recovery manifest byte budget exhausted".to_owned() + )) } let operations = (header.generation_key_envelopes.len() as u64) .checked_mul(2) @@ -174,7 +180,9 @@ impl RecoveryBudget { .checked_add(operations) .context("recovery crypto budget overflow")?; if self.crypto_operations > MAX_RECOVERY_CRYPTO_OPERATIONS { - bail!("recovery cryptographic-operation budget exhausted") + bail!(BudgetExhausted( + "recovery cryptographic-operation budget exhausted".to_owned() + )) } Ok(()) } @@ -904,7 +912,7 @@ impl EncryptedRepository { let observation = match self.storage.observe_head() { Ok(observation) => observation, Err(error) => { - if format!("{error:#}").contains("budget exhausted") { + if error.is::() { return Err(error).context("bounded recovery discovery failed"); } report.reason = format!("storage head is unavailable: {error:#}"); @@ -928,7 +936,7 @@ impl EncryptedRepository { None if observation.head_bytes.is_some() => Err(anyhow::anyhow!( "storage HEAD is not a valid manifest object ID" )), - None => Err(anyhow::anyhow!("encrypted repository has no storage HEAD")), + None => Err(MissingStorageHead.into()), }; match current_chain { Ok(chain) => { @@ -974,11 +982,10 @@ impl EncryptedRepository { } } Err(error) => { - if format!("{error:#}").contains("budget exhausted") { + if error.is::() { return Err(error).context("bounded recovery discovery failed"); } - let (class, reason) = - classify_recovery_error(&error, observation.head_bytes.as_deref()); + let (class, reason) = classify_recovery_error(&error); report.classification = class; report.reason = reason; if class == RecoveryClass::Invalid @@ -1040,7 +1047,9 @@ impl EncryptedRepository { let carrier_history = history.is_some(); let history = history.unwrap_or_default(); if history.commits.len() > MAX_RECOVERY_OUTER_COMMITS { - bail!("recovery outer-commit budget exhausted") + bail!(BudgetExhausted( + "recovery outer-commit budget exhausted".to_owned() + )) } let historical_bytes = history.manifests.values().try_fold(0_u64, |total, bytes| { total @@ -1048,14 +1057,18 @@ impl EncryptedRepository { .context("recovery manifest byte budget overflow") })?; if historical_bytes > MAX_RECOVERY_MANIFEST_BYTES { - bail!("recovery manifest byte budget exhausted") + bail!(BudgetExhausted( + "recovery manifest byte budget exhausted".to_owned() + )) } budget.bytes_read = budget .bytes_read .checked_add(historical_bytes) .context("recovery manifest byte budget overflow")?; if budget.bytes_read > MAX_RECOVERY_BYTES { - bail!("recovery manifest byte budget exhausted") + bail!(BudgetExhausted( + "recovery manifest byte budget exhausted".to_owned() + )) } if carrier_history && !history @@ -1074,9 +1087,11 @@ impl EncryptedRepository { match self.read_manifest_material(id) { Ok(material) => Ok(material), Err(current_error) => { - let bytes = history.manifests.get(id).with_context(|| { - format!("historical manifest {id} is unavailable (current read: {current_error:#})") - })?; + let Some(bytes) = history.manifests.get(id) else { + return Err(current_error.context(format!( + "historical manifest {id} is not in carrier history" + ))); + }; if object_id(bytes) != id { bail!("historical manifest {id} content ID mismatch") } @@ -1150,7 +1165,9 @@ impl EncryptedRepository { } } if unique_heads.len() > MAX_RECOVERY_CANDIDATES { - bail!("recovery candidate budget exhausted") + bail!(BudgetExhausted( + "recovery candidate budget exhausted".to_owned() + )) } for (candidate_id, commit) in unique_heads { if candidate_id == floor_id { @@ -1163,7 +1180,7 @@ impl EncryptedRepository { ) { Ok(chain) => chain, Err(error) => { - let (class, _) = classify_recovery_error(&error, None); + let (class, _) = classify_recovery_error(&error); match class { RecoveryClass::Invalid => continue, RecoveryClass::Unverifiable @@ -1543,7 +1560,9 @@ impl EncryptedRepository { (policy, Some(parent.policy.clone())) } ManifestAuthorization::Checkpoint => { - bail!("checkpoint transitions are reserved but not implemented") + bail!(Unsupported( + "checkpoint transitions are reserved but not implemented".to_owned() + )) } }; manifest.validate_successor(&parent.id, &parent.manifest, &policy)?; @@ -1690,33 +1709,26 @@ struct RejectedHeadLog { entries: Vec, } -fn classify_recovery_error( - error: &anyhow::Error, - raw_head: Option<&[u8]>, -) -> (RecoveryClass, String) { - let detail = format!("{error:#}"); - if detail.contains("unsupported") || detail.contains("reserved but not implemented") { - return (RecoveryClass::Unsupported, detail); - } - if detail.contains("device is not an active reader") - || detail.contains("cannot open the generation key") - { - return (RecoveryClass::Unverifiable, detail); - } - if detail.contains("key file belongs to a different repository") { - return (RecoveryClass::Discontinuous, detail); - } - if error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|source| source.kind() != io::ErrorKind::InvalidData) +fn classify_recovery_error(error: &anyhow::Error) -> (RecoveryClass, String) { + let has = |matches: fn(&(dyn std::error::Error + 'static)) -> bool| error.chain().any(matches); + let class = if has(|cause| cause.is::()) { + RecoveryClass::Unsupported + } else if has(|cause| cause.is::()) { + RecoveryClass::Unverifiable + } else if has(|cause| cause.is::()) { + RecoveryClass::Discontinuous + } else if has(|cause| { + cause.is::() + || cause.is::() + || cause + .downcast_ref::() + .is_some_and(|source| source.kind() != io::ErrorKind::InvalidData) }) { - return (RecoveryClass::Unavailable, detail); - } - if detail.contains("encrypted repository has no storage HEAD") && raw_head.is_none() { - return (RecoveryClass::Unavailable, detail); - } - (RecoveryClass::Invalid, detail) + RecoveryClass::Unavailable + } else { + RecoveryClass::Invalid + }; + (class, format!("{error:#}")) } fn record_rejected_head( @@ -1982,7 +1994,13 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; - use super::{ClientState, read_client_state, testing_write_client_state}; + use std::io; + + use super::{ + ClientState, ForeignRepository, MissingStorageHead, NotActiveReader, ObjectUnavailable, + RecoveryClass, Unsupported, classify_recovery_error, read_client_state, + testing_write_client_state, + }; #[test] fn concurrent_client_state_process_writes_never_lower_or_corrupt_the_floor() { @@ -2083,4 +2101,50 @@ mod tests { let state: ClientState = serde_json::from_slice(legacy).unwrap(); assert!(state.verified_refs.is_empty()); } + + #[test] + fn recovery_classification_ignores_storage_controlled_message_text() { + for text in [ + "parse manifest header: unknown field `unsupported`", + "checkpoint transitions are reserved but not implemented", + "device is not an active reader of the current generation", + "key file belongs to a different repository", + "carrier object 00 is unavailable", + "encrypted repository has no storage HEAD", + "recovery manifest byte budget exhausted", + ] { + let (class, _) = classify_recovery_error(&anyhow::anyhow!(text.to_owned())); + assert_eq!(class, RecoveryClass::Invalid, "{text}"); + } + } + + #[test] + fn recovery_classification_follows_error_types_through_context() { + let cases: [(anyhow::Error, RecoveryClass); 6] = [ + ( + Unsupported("manifest format 99".to_owned()).into(), + RecoveryClass::Unsupported, + ), + (NotActiveReader.into(), RecoveryClass::Unverifiable), + (ForeignRepository.into(), RecoveryClass::Discontinuous), + ( + ObjectUnavailable { + storage: "carrier", + id: "00".to_owned(), + source: None, + } + .into(), + RecoveryClass::Unavailable, + ), + (MissingStorageHead.into(), RecoveryClass::Unavailable), + ( + io::Error::from(io::ErrorKind::InvalidData).into(), + RecoveryClass::Invalid, + ), + ]; + for (error, expected) in cases { + let error = error.context("read manifest"); + assert_eq!(classify_recovery_error(&error).0, expected, "{error:#}"); + } + } } diff --git a/src/storage.rs b/src/storage.rs index e4b0bf1..f4dd09d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -13,6 +13,7 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::crypto::object_id; +use crate::error::{BudgetExhausted, ObjectUnavailable}; use crate::trace; #[derive(Debug, Error)] @@ -363,9 +364,14 @@ impl Storage for FilesystemStorage { fn open_object(&self, kind: ObjectKind, id: &str) -> Result> { let path = self.object_path(kind, id)?; - Ok(Box::new( - File::open(&path).with_context(|| format!("read {}", path.display()))?, - )) + let file = File::open(&path) + .map_err(|source| ObjectUnavailable { + storage: "directory", + id: id.to_owned(), + source: Some(source), + }) + .with_context(|| format!("read {}", path.display()))?; + Ok(Box::new(file)) } fn read_head(&self) -> Result> { @@ -505,7 +511,9 @@ fn read_bounded_head_file(path: &Path) -> Result>> { Err(error) => return Err(error.into()), }; if file.metadata()?.len() > MAX_STORAGE_HEAD_BYTES { - bail!("recovery outer-head byte budget exhausted") + bail!(BudgetExhausted( + "recovery outer-head byte budget exhausted".to_owned() + )) } let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; @@ -1059,7 +1067,9 @@ impl GitStorage { let size = String::from_utf8(size.stdout)?.trim().parse::()?; if size > max_bytes { if name == "HEAD" { - bail!("recovery outer-head byte budget exhausted") + bail!(BudgetExhausted( + "recovery outer-head byte budget exhausted".to_owned() + )) } bail!("carrier {name} exceeds its size budget") } @@ -1143,7 +1153,9 @@ impl GitStorage { .checked_add(length as u64) .context("historical carrier object size overflow")?; if next_length > max_bytes { - bail!("carrier recovery manifest byte budget exhausted") + bail!(BudgetExhausted( + "carrier recovery manifest byte budget exhausted".to_owned() + )) } bytes.extend_from_slice(&output.stdout); } @@ -1267,12 +1279,17 @@ impl Storage for GitStorage { trace: trace::Io::new("carrier_object_read"), })); } + let unavailable = || ObjectUnavailable { + storage: "carrier", + id: id.to_owned(), + source: None, + }; let Some(commit) = git_rev_parse(self.root(), "HEAD")? else { - bail!("carrier object {id} is unavailable") + bail!(unavailable()) }; let chunks = self .chunk_entries_at(&commit, kind, id)? - .with_context(|| format!("carrier object {id} is unavailable"))?; + .ok_or_else(unavailable)?; Ok(Box::new(GitChunkReader::new(self.root(), chunks)?)) } @@ -1398,7 +1415,9 @@ impl Storage for GitStorage { ) -> Result> { let commits = self.commit_history(max_commits)?; if commits.len() > max_commits { - bail!("carrier recovery outer-commit budget exhausted") + bail!(BudgetExhausted( + "carrier recovery outer-commit budget exhausted".to_owned() + )) } let mut history = RecoveryHistory::default(); let mut total_bytes = 0_u64; @@ -1424,7 +1443,9 @@ impl Storage for GitStorage { .checked_add(bytes.len() as u64) .context("carrier recovery manifest byte count overflow")?; if total_bytes > max_manifest_bytes { - bail!("carrier recovery manifest byte budget exhausted") + bail!(BudgetExhausted( + "carrier recovery manifest byte budget exhausted".to_owned() + )) } history.manifests.insert(id.clone(), bytes); } @@ -1437,7 +1458,9 @@ impl Storage for GitStorage { fn restore_historical_object(&self, kind: ObjectKind, id: &str) -> Result { let commits = self.commit_history(MAX_RECOVERY_OUTER_COMMITS)?; if commits.len() > MAX_RECOVERY_OUTER_COMMITS { - bail!("carrier recovery outer-commit budget exhausted") + bail!(BudgetExhausted( + "carrier recovery outer-commit budget exhausted".to_owned() + )) } for commit in commits { if self.restore_object_at(&commit, kind, id)? { diff --git a/tests/carrier_git.rs b/tests/carrier_git.rs index 48bc576..b9a272e 100644 --- a/tests/carrier_git.rs +++ b/tests/carrier_git.rs @@ -1806,3 +1806,155 @@ fn carrier_init_refuses_an_existing_branch_and_another_branch_works() { ); assert!(!git(&carrier, &["rev-parse", "refs/heads/vault"], false).is_empty()); } + +#[test] +fn carrier_recovery_classifies_missing_head_manifest_as_unavailable() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + let source = temporary.path().join("source"); + let returning = temporary.path().join("returning"); + init_source(&source, "first content"); + init_source(&returning, "returning local content"); + + let owner = KeyFile::generate(); + init_carrier(temporary.path(), &carrier, &owner); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + + fs::write(source.join("note.md"), "second content\n").unwrap(); + git(&source, &["commit", "-q", "-am", "second"], false); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + let (head_id, _) = repository.current_manifest().unwrap(); + + edit_carrier( + &carrier, + &temporary.path().join("carrier-edit"), + |workdir| { + fs::remove_dir_all(workdir.join("manifests").join(&head_id[..2]).join(&head_id)) + .unwrap(); + }, + ); + + let recover = |publish| { + EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ) + .recover( + &returning, + RecoveryOptions { + publish, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap() + }; + let report = recover(false); + assert_eq!( + report.classification, + RecoveryClass::Unavailable, + "{}", + report.reason + ); + + let published = recover(true); + assert_eq!(published.classification, RecoveryClass::Unavailable); + assert!(published.published_manifest.is_none()); + assert!(published.blocked_reason.is_some()); + let carrier_head = git(&carrier, &["show", "refs/heads/main:HEAD"], false); + assert_eq!(carrier_head, head_id); +} + +#[test] +fn carrier_recovery_refuses_to_skip_a_historical_head_with_missing_objects() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + let source = temporary.path().join("source"); + let returning = temporary.path().join("returning"); + init_source(&source, "floor content"); + init_source(&returning, "returning local content"); + + let owner = KeyFile::generate(); + init_carrier(temporary.path(), &carrier, &owner); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + let (floor_id, floor) = repository.current_manifest().unwrap(); + + let uncommitted = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let genesis_id = floor.previous.clone().unwrap(); + let genesis_bytes = uncommitted + .get_object(ObjectKind::Manifest, &genesis_id) + .unwrap(); + let genesis_key = + unwrap_generation_key(&peek_manifest_header(&genesis_bytes).unwrap(), &owner).unwrap(); + let genesis = open_manifest(&genesis_bytes, &genesis_key).unwrap(); + let policy = + PolicyState::parse(genesis.manifest().introduced_policy.as_deref().unwrap()).unwrap(); + let newer_id = + create_writer_successor(&uncommitted, &owner, &policy, &floor_id, floor.refs.clone()); + + edit_carrier( + &carrier, + &temporary.path().join("carrier-edit"), + |workdir| { + fs::write(workdir.join("HEAD"), format!("{newer_id}\n")).unwrap(); + }, + ); + let junk_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk = b"invalid successor"; + let junk_id = object_id(junk); + junk_storage + .put_object_if_absent(ObjectKind::Manifest, &junk_id, junk) + .unwrap(); + junk_storage + .compare_and_swap_head(Some(&newer_id), &junk_id) + .unwrap(); + + let report = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ) + .recover( + &returning, + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .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 {newer_id} is unavailable" + )), + "{blocked}" + ); + let carrier_head = git(&carrier, &["show", "refs/heads/main:HEAD"], false); + assert_eq!(carrier_head, junk_id); +}