diff --git a/docs/design.md b/docs/design.md index fb99c46..7c03b17 100644 --- a/docs/design.md +++ b/docs/design.md @@ -122,7 +122,18 @@ administrative CAS is never automatically rebased. - Packs are decrypted segment by segment directly into `git index-pack` oldest-first. - After import, every advertised ref must resolve to a complete local Git - object graph before pins or remote-tracking refs move. + object graph before the continuity floor or remote-tracking refs move. +- Authentication alone never advances the continuity floor. The floor advances + after a successful import and connectivity check, after the client's own + publication wins compare-and-swap, or during listing when every advertised + ref already resolves to a complete local object graph. The last case matters + because Git skips the helper's fetch command when nothing needs to be + transferred, as after a membership change, a deletion, or a tag pointing at + an existing object; without it, such a state would never be pinned and a + later replay of the previous state would be accepted. +- Per-repository/remote client-state updates hold an advisory file lock across + validation and read-modify-write. The state replacement remains durable and + atomic, and its manifest and policy generations never move backward. - The helper advertises `refs/heads/*` and `refs/tags/*`; every other ref namespace is rejected before publication. - Branch updates require fast-forward ancestry unless force is explicit. Tags @@ -213,7 +224,23 @@ motivates future garbage collection. Returning clients pin repository root, manifest ID/generation, and policy generation. They reject rollback, non-descendant state, policy rollback, and -root substitution. +root substitution. A signed manifest seen by list is not yet a pin: helper +state advances after all required packs are imported and every advertised ref +passes the complete-object-graph check, or after the client's own publication +successfully wins compare-and-swap. A missing or unusable pack therefore cannot +raise the continuity floor. + +Each local repository/remote pair has one stable lock file beside its client +state. Helpers hold the exclusive lock while checking the current floor and +performing an import or publication update. State writes merge monotonically +and use the existing flushed temporary-file, rename, and parent-directory +sync path. Administrator pins use the same serialized, monotonic state update +rules after initialization or a successful policy transition. + +State files written by earlier builds are not lowered automatically. Their +head pin may have come from listing, but it is indistinguishable from a valid +pin made after the client's own publication; lowering it could weaken rollback +detection. Storage alone still cannot prevent: @@ -227,6 +254,131 @@ Old manifest headers retain device envelopes. Later compromise of a device private key can retroactively expose every generation addressed to that device. There is no forward secrecy for stored history. +## Recovering from an unverifiable HEAD + +Anyone with storage write access can point `HEAD` at something conforming +clients reject: bytes that fail to parse, a manifest signed by a reader-only +device, or a manifest that fails any other check. Clients already refuse such +a state and stop. Recovery lets an honest writer continue afterwards without +trusting anything the rejected state claims. It is an explicit operation, +`git-e2ee recover`; nothing recovers automatically. + +### Principle + +Recovery never derives a base, refs, policy, keys, or pack inventory from the +rejected state. The default base is the client's continuity floor: the newest +state it accepted after a complete connectivity check or its own successful +publication. A fully validated descendant of the floor may be chosen +explicitly. Anything else storage offers, including a rejected manifest's +`previous` pointer, is only a discovery hint and is validated independently. + +The danger is an update this client never saw. If a legitimate state `L` +followed the floor and storage later replaced it, continuing from the floor +would drop `L`, and if `L` revoked a device, would deliver new keys to that +device again. The client cannot rule this out from the floor alone, so +recovery shows the user what it found and never chooses silently. + +### Classifying the head + +| Class | Meaning | Recovery allowed | +|---|---|---| +| valid | authenticated, authorized, continuous with the floor | not needed | +| invalid | demonstrably bad: unparseable, bad signature, signer not authorized under the authenticated policy, broken audit, commitment, or predecessor link | yes | +| unverifiable | this device cannot open any envelope | never: a valid revocation looks exactly like this to the revoked device | +| unsupported | unknown format version or transition type, even if the parser reports it as a parse error | never: an old client must not erase a newer client's state | +| unavailable | objects missing or a storage error, including failure to read a historical object | never | +| discontinuous | authenticated but older than, or diverging from, the floor | never: this is detected rollback or equivocation and is reported as such | + +These restrictions bind `recover` as well; it cannot override them. A signer ID +read from a sealed header is reported as the claimed signer until its signature +verifies against a device record in the authenticated policy. + +### `git-e2ee recover` + +1. **Discover, read-only.** Classify the head. On the carrier backend, walk the + outer commit history back to the floor's manifest within a fixed budget and + list every authenticated manifest that descends from the floor, with its + generation, outer commit, verified signer, and whether candidates conflict. + A replay of an older manifest found in outer history is reported, never + treated as newer. Exhausting the budget is an error, not a choice. +2. **Refuse ambiguity.** If authenticated descendants conflict, report a fork + and stop. +3. **Select explicitly.** The default offer is the newest validated descendant + of the floor, or the floor itself if none exists. Choosing an older base + than one found is a deliberate history-discarding choice and requires a + separate confirmation. On the directory backend, which has no `HEAD` + history, only the floor can be offered, with the confirmation: "Updates + after this floor may be omitted. This may also omit revocations and + disclose subsequently published content to devices excluded by a newer + policy." +4. **Publish.** The device must be an active writer under the base's policy. + Build `R` with `R.previous = base`, `R.generation = base.generation + 1`, + the predecessor link to the base key, refs and policy from the base, and + compare-and-swap from the observed storage token, which is the current outer + tip on the carrier. The verified base and the CAS token are separate values. + On the carrier the new outer commit is a child of the current tip, so the + push is still a fast-forward. Rebuild the `e2ee/` tree from the ciphertext + objects the base chain requires, restored byte for byte from the carrier's + history or the local cache and checked against their content IDs; if any is + missing, fail as unavailable. A lost CAS fails; the operator must start a + new `recover` invocation and review its fresh discovery report. + +Git does not pass a push of unchanged refs to the helper, so a plain `git push` +cannot perform recovery; `recover` is the only entry point. It must never +publish during `list for-push`. + +Fresh clones have no floor. Unless they hold an authenticated invitation +checkpoint, `recover` states that freshness and fork identity are unverified. + +### Possible automatic recovery later + +Automatic recovery is safe only if the client can prove nothing legitimate +came between its floor and the junk. Comparing the parent's inner `HEAD` value +with the floor is not enough: storage writers can append a legitimate `L`, then +a replay of the floor, then junk, and the junk's parent would still match. A +future automatic mode would have to anchor to the exact outer commit at which +this client established its floor, never move that anchor on a replay, and +require the current tip to be a single-parent child of it. + +### Implementation requirements + +- Validate every accepted ancestor under the policy in force at that point of + the chain; never import policy authority from a rejected manifest. +- Bound discovery by outer commits, candidates, bytes read, and cryptographic + operations, deduplicating repeated IDs. +- Record rejected heads as bounded diagnostic evidence next to the client + state, separate from the floor. Do not blacklist unverifiable or unavailable + states. +- Never lower the floor. A successful recovery advances it to `R`. + +### Current implementation + +`git-e2ee recover` accepts `--remote ` or `--storage `, +plus `--key`, `--repo`, and `--remote-name`. The repository defaults to `.` +and the remote name defaults to `origin`; the continuity floor is read from +`.git/git-remote-e2ee//state.json`. Without `--publish`, the command +only reports. Publishing an older offer requires `--discard-newer`. Directory +publication requires `--accept-stale-floor` and prints the warning above. + +Discovery scans the available carrier history and refuses any history that +contains a merge commit, since legitimate publications are always +single-parent and a merge could hide a legitimate state behind its second +parent. It works within +budgets of 2,048 outer commits, 256 distinct heads, 64 MiB of manifest bytes, +and 1,000,000 estimated cryptographic operations. It scans past replays of the +floor so a legitimate descendant hidden behind a replay can still be offered. +An exhausted budget fails closed. A CAS loss is returned to the operator; +there is no automatic retry with an old selection. This implements the +restart-at-discovery requirement as a fresh command invocation, matching the +requirement that a losing recovery fail without retrying blindly. + +Before publication, required manifests and packs are restored into the +disposable carrier checkout, unneeded ciphertext objects are removed from that +checkout's `e2ee/` tree, and all base-chain packs are imported into a temporary +bare Git repository to verify complete ref connectivity. The temporary +repository is deleted when the command exits; the persistent carrier cache +continues to contain ciphertext Git objects only. + ## Anonymous recipients (v5) v4 let storage see each device's per-repository public keys, its roles, and diff --git a/docs/spec.md b/docs/spec.md index db5a962..e444af2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -23,7 +23,8 @@ It is designed to satisfy all of the following: envelopes scale with the padded reader count; a policy transition also carries its encrypted policy body. 5. A newly admitted reader receives full history by default. -6. Concurrent publications have exactly one visible winner. +6. Concurrent publications have exactly one visible winner, provided storage + honors the compare-and-swap contract. The protocol provides confidentiality, authenticity, integrity, and continuity from state pinned by the same client. It does not provide global freshness or @@ -292,8 +293,10 @@ bytes, and verify SHA-256 of the complete stored stream against the descriptor ID. A malformed header, size mismatch, counter overflow, short read, reordered or duplicated segment, authentication failure, trailing byte, or hash mismatch is fatal. Plaintext may stream into `git index-pack` before the final object hash -is known, but no ref or continuity pin may move until all cryptographic, -content-address, process, and Git-connectivity checks succeed. +is known, but no imported-state pin or remote ref may move until all +cryptographic, content-address, process, and Git-connectivity checks succeed. +Listing an authenticated manifest does not advance the continuity floor. A +publisher advances its own floor only after its compare-and-swap succeeds. Immutable writes MUST be staged within the storage backend and become visible under their ciphertext ID only after the complete streamed hash matches that @@ -402,6 +405,24 @@ refs, pinning the new head, or publishing a successor. A signed ref advance whose required pack delta is absent is invalid even when every cryptographic check succeeds. +Authenticating a manifest alone MUST NOT advance the continuity floor. A +helper client's head, generation, repository root, and policy-generation floor +advances only when the complete-ref connectivity check succeeds for that +manifest's refs, either after pack import or, during listing, because every +advertised ref is already connected locally; or after that client successfully +publishes its own successor with compare-and-swap. A client MUST NOT rely on +receiving a fetch command to pin a state, because Git omits it when no objects +need to be transferred. A failed or incomplete +import leaves the previous floor intact, so an authenticated but unusable +publication cannot make a later valid successor look like rollback. + +Client-state updates for one repository and remote MUST serialize their +read-modify-write operation across helper processes. The merge MUST preserve +the greatest recorded manifest and policy generations and MUST reject a +same-generation head-ID conflict. The durable state-file replacement remains +atomic; the lock is held while an import or publication checks and advances +the floor. + A client MAY satisfy that requirement incrementally. After a successful full or incremental connectivity check, it records the exact checked ref tips as a verified frontier. On a later fetch it first requires every frontier tip to @@ -417,6 +438,54 @@ client state from another wire version is not accepted. The incremental rule ass client's existing object database has not been corrupted outside this protocol; explicit full verification remains available to check local storage. +### Explicit recovery + +`git-e2ee recover` MUST default to a read-only report. It MUST classify the +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 +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. + +On carrier Git, discovery MUST inspect bounded outer history and MUST refuse +to proceed if that history contains any merge commit: every legitimate +publication is a single-parent fast-forward, and a merge can hide a legitimate +state behind a non-first parent. Discovery MUST deduplicate manifest IDs and +validate candidates against the authenticated policy and predecessor chain. The byte, outer-commit, candidate, and +cryptographic-operation budgets MUST be fixed. Exhaustion MUST fail closed. +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 +outer history and MUST offer only the pinned floor. + +The default base MUST be the newest validated descendant of the floor, or the +floor when no descendant is available. Selecting an older offered base MUST +require an explicit history-discard confirmation. Directory publication MUST +require explicit acknowledgement that updates and revocations after the floor +may be omitted and that subsequently published content may be disclosed to +devices excluded by a newer policy. + +Before publication, the device MUST be an active writer under the selected +base's policy. Recovery MUST publish a normal signed writer successor whose +previous ID and generation are the selected base and its generation plus one, +whose refs and policy are copied from that base, and whose predecessor link +opens to the base key. It MUST compare-and-swap from the observed storage token +while keeping that token separate from the selected manifest base. Required +base-chain ciphertext MUST be available and checked against content IDs. A +carrier publication MUST restore required historical ciphertext byte-for-byte, +publish a child of the observed outer tip, and use a normal fast-forward push. +Before publishing, the selected refs MUST resolve to complete local Git object +graphs. A lost CAS MUST fail without a blind retry. + +Recovery MUST record invalid rejected heads as bounded diagnostic evidence +separate from client continuity state. That evidence MUST NOT lower the floor +or make an unverifiable or unavailable head ineligible for a later valid +operation. A successful recovery advances the floor only after its own CAS +succeeds. Recovery MUST NOT run from remote-helper `list` or `list for-push`, +and an ordinary `git push` MUST NOT trigger it. + Force pushes append a normal pack delta and new ref state. Older packs remain in historical manifests and may become unreachable in the inner Git graph. @@ -424,7 +493,10 @@ in historical manifests and may become unreachable in the inner Git graph. The server does not inspect encrypted inner commit parents. Two writers read the same opaque `HEAD`, create immutable candidates, and attempt the same CAS. -Exactly one wins. +Against storage that honors the CAS contract, exactly one wins. Malicious or +noncompliant storage can report outcomes inconsistently; that is outside the +guarantees, and a noncooperating writer to directory storage can also cause an +ABA change of the compared `HEAD` value. - Filesystem storage uses locking. `HEAD` is published by rename inside the storage root. Immutable objects are hard-linked from `.staging` into @@ -441,9 +513,14 @@ Unreachable losing objects require later GC. ## 11. Continuity and invitations -Returning clients pin at least repository root, manifest ID and generation, -and policy ID and generation. They reject rollback, a non-descendant manifest, -policy rollback/sideways movement, root substitution, and generation gaps. +Returning clients pin repository root, manifest ID and generation, and policy +generation. The policy ID is not stored separately: the pinned manifest ID +binds it through the manifest's sealed header. Helper continuity pins advance +only after the complete-ref connectivity check succeeds (after import, or +during listing when the advertised refs are already connected locally) or +after a successful own publication, never from authentication alone. They reject rollback, a non-descendant manifest, policy +rollback/sideways movement, root substitution, and generation gaps. Local +client-state updates are serialized and monotonic across concurrent helpers. A fresh device should receive an administrator-authorized invitation checkpoint through an authenticated channel containing: diff --git a/src/main.rs b/src/main.rs index 69bc0da..13106b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Result, bail}; use clap::{Parser, Subcommand, ValueEnum}; use git_remote_e2ee::crypto::{KeyFile, PublicDevice}; use git_remote_e2ee::policy::DeviceRoles; -use git_remote_e2ee::repository::EncryptedRepository; +use git_remote_e2ee::repository::{EncryptedRepository, RecoveryOptions, RecoveryReport}; use git_remote_e2ee::storage::{FilesystemStorage, GitStorage}; #[derive(Parser)] @@ -116,6 +116,26 @@ enum Command { #[arg(long)] key: PathBuf, }, + Recover { + #[arg(long, conflicts_with = "remote", required_unless_present = "remote")] + storage: Option, + #[arg(long, conflicts_with = "storage", required_unless_present = "storage")] + remote: Option, + #[arg(long)] + key: PathBuf, + #[arg(long, default_value = ".")] + repo: PathBuf, + #[arg(long, default_value = "origin")] + remote_name: String, + #[arg(long)] + publish: bool, + #[arg(long, requires = "publish")] + base: Option, + #[arg(long, requires = "publish")] + discard_newer: bool, + #[arg(long, requires = "publish")] + accept_stale_floor: bool, + }, } fn main() -> Result<()> { @@ -259,10 +279,103 @@ fn main() -> Result<()> { ); } } + Command::Recover { + storage, + remote, + key, + repo, + remote_name, + publish, + base, + discard_newer, + accept_stale_floor, + } => { + let key = KeyFile::read(&key)?; + let options = RecoveryOptions { + publish, + base: base.as_deref(), + discard_newer, + accept_stale_floor, + }; + let report = match (storage, remote) { + (Some(storage), None) => EncryptedRepository::new( + FilesystemStorage::new(storage), + key, + ) + .recover(&repo, &remote_name, options)?, + (None, Some(remote)) => EncryptedRepository::new(GitStorage::open(&remote)?, key) + .recover(&repo, &remote_name, options)?, + _ => unreachable!("clap requires exactly one backend"), + }; + print_recovery_report(&report); + if publish && let Some(reason) = report.blocked_reason.as_deref() { + bail!("recovery refused: {reason}") + } + } } Ok(()) } +fn print_recovery_report(report: &RecoveryReport) { + println!("{}", format_recovery_report(report)); +} + +fn format_recovery_report(report: &RecoveryReport) -> String { + let mut lines = vec![format!( + "storage head: {} ({})", + report.classification.name(), + report.reason + )]; + if let Some(head) = &report.head_id { + lines.push(format!("head manifest: {head}")); + } + if let Some(signer) = &report.claimed_signer { + if report.classification.name() == "invalid" { + lines.push(format!("claimed signer (signature not verified): {signer}")); + } else { + lines.push(format!("verified signer: {signer}")); + } + } + if let (Some(floor), Some(generation)) = (&report.floor_id, report.floor_generation) { + lines.push(format!( + "continuity floor: {floor} (generation {generation})" + )); + } else { + lines.push( + "continuity floor: unavailable; freshness and fork identity are unverified".to_owned(), + ); + } + for candidate in &report.candidates { + let outer = candidate + .outer_commit + .as_deref() + .unwrap_or("directory backend"); + lines.push(format!( + "candidate: {} generation={} outer={} verified-signer={}", + candidate.manifest_id, candidate.generation, outer, candidate.verified_signer + )); + } + for replay in &report.replays { + lines.push(format!("replay: {replay}")); + } + if report.conflict { + lines.push("conflict: authenticated descendants form a fork".to_owned()); + } + if let Some(base) = &report.default_base { + lines.push(format!("default base: {base}")); + } + if let Some(warning) = &report.warning { + lines.push(format!("warning: {warning}")); + } + if let Some(blocked) = &report.blocked_reason { + lines.push(format!("recovery refused: {blocked}")); + } + if let Some(manifest) = &report.published_manifest { + lines.push(format!("published recovery manifest: {manifest}")); + } + lines.join("\n") +} + fn role_name(roles: &DeviceRoles) -> &'static str { if roles.administrator { "admin" @@ -290,3 +403,90 @@ fn open_repository( KeyFile::read(&key)?, )) } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Command, format_recovery_report}; + use git_remote_e2ee::repository::{RecoveryClass, RecoveryReport}; + + #[test] + fn recover_cli_defaults_to_report_and_accepts_explicit_publication_flags() { + let cli = Cli::try_parse_from([ + "git-e2ee", + "recover", + "--storage", + "/tmp/carrier", + "--key", + "/tmp/key.json", + ]) + .unwrap(); + match cli.command { + Command::Recover { + publish, + remote_name, + accept_stale_floor, + .. + } => { + assert!(!publish); + assert_eq!(remote_name, "origin"); + assert!(!accept_stale_floor); + } + _ => panic!("parsed a different command"), + } + + assert!( + Cli::try_parse_from([ + "git-e2ee", + "recover", + "--storage", + "/tmp/carrier", + "--key", + "/tmp/key.json", + "--discard-newer", + ]) + .is_err() + ); + let cli = Cli::try_parse_from([ + "git-e2ee", + "recover", + "--remote", + "carrier.git", + "--key", + "key.json", + "--publish", + "--base", + "deadbeef", + "--discard-newer", + "--accept-stale-floor", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Command::Recover { publish: true, .. } + )); + } + + #[test] + fn invalid_signer_output_is_labeled_as_a_claim() { + let report = RecoveryReport { + classification: RecoveryClass::Invalid, + reason: "signature verification failed".to_owned(), + head_id: Some("head-id".to_owned()), + claimed_signer: Some("device-id".to_owned()), + floor_id: None, + floor_generation: None, + candidates: Vec::new(), + default_base: None, + replays: Vec::new(), + conflict: false, + freshness_unverified: true, + warning: None, + blocked_reason: None, + published_manifest: None, + }; + let output = format_recovery_report(&report); + assert!(output.contains("claimed signer (signature not verified): device-id")); + } +} diff --git a/src/manifest.rs b/src/manifest.rs index 6554e04..2e354a8 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -212,6 +212,7 @@ struct ManifestEnvelope { pub struct OpenedManifest { pub(crate) header: ManifestHeader, pub(crate) manifest: Manifest, + pub(crate) claimed_signer_id: String, exact_header: Vec, exact_sealed_content: Vec, signature: Vec, @@ -409,6 +410,7 @@ pub fn open_manifest(encrypted: &[u8], generation_key: &[u8; 32]) -> Result Result &'static str { + match self { + Self::Valid => "valid", + Self::Invalid => "invalid", + Self::Unverifiable => "unverifiable", + Self::Unsupported => "unsupported", + Self::Unavailable => "unavailable", + Self::Discontinuous => "discontinuous", + } + } +} + +#[derive(Clone, Debug)] +pub struct RecoveryCandidate { + pub manifest_id: String, + pub generation: u64, + pub outer_commit: Option, + pub verified_signer: String, +} + +#[derive(Clone, Debug)] +pub struct RecoveryReport { + pub classification: RecoveryClass, + pub reason: String, + pub head_id: Option, + pub claimed_signer: Option, + pub floor_id: Option, + pub floor_generation: Option, + pub candidates: Vec, + pub default_base: Option, + pub replays: Vec, + pub conflict: bool, + pub freshness_unverified: bool, + pub warning: Option, + pub blocked_reason: Option, + pub published_manifest: Option, +} + +pub struct RecoveryOptions<'a> { + pub publish: bool, + pub base: Option<&'a str>, + pub discard_newer: bool, + pub accept_stale_floor: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] struct ClientState { #[serde(default)] format_version: u64, #[serde(default)] packs: Vec, + // Continuity floor: imported and connectivity-verified, or our own CAS winner. head_id: Option, generation: Option, + // The last generation whose pack deltas and advertised refs we imported and checked. #[serde(default)] imported_generation: Option, #[serde(default)] @@ -46,6 +115,62 @@ struct OpenedState { header: ManifestHeader, policy: PolicyState, generation_key: SecretKey, + verified_signer: String, +} + +struct CommitHead<'a> { + previous: Option<&'a OpenedState>, + observation: Option<&'a HeadObservation>, +} + +struct ClientStateLock { + _file: File, +} + +struct RecoveryBudget { + crypto_operations: u64, + bytes_read: u64, +} + +impl RecoveryBudget { + fn charge_manifest(&mut self, header: &ManifestHeader, byte_count: usize) -> Result<()> { + self.bytes_read = self + .bytes_read + .checked_add(byte_count as u64) + .context("recovery byte budget overflow")?; + if self.bytes_read > MAX_RECOVERY_BYTES { + bail!("recovery manifest byte budget exhausted") + } + let operations = (header.generation_key_envelopes.len() as u64) + .checked_mul(2) + .and_then(|value| value.checked_add(1)) + .context("recovery crypto budget overflow")?; + self.crypto_operations = self + .crypto_operations + .checked_add(operations) + .context("recovery crypto budget overflow")?; + if self.crypto_operations > MAX_RECOVERY_CRYPTO_OPERATIONS { + bail!("recovery cryptographic-operation budget exhausted") + } + Ok(()) + } +} + +impl ClientStateLock { + fn acquire(path: &Path) -> Result { + // Lock a stable sibling: persist_client_state atomically replaces the state file. + let lock_path = path.with_extension("json.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("open client state lock {}", lock_path.display()))?; + FileExt::lock_exclusive(&file) + .with_context(|| format!("lock client state {}", path.display()))?; + Ok(Self { _file: file }) + } } pub struct EncryptedRepository { @@ -81,6 +206,7 @@ impl EncryptedRepository { roles: DeviceRoles, pin_path: &Path, ) -> Result<(String, String)> { + let _pin_lock = ClientStateLock::acquire(pin_path)?; let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?.clone(); self.validate_pinned_head(&chain, &read_client_state(pin_path)?)?; @@ -100,7 +226,7 @@ impl EncryptedRepository { let next = transition_manifest(¤t, &next_policy, policy_bytes, &next_key)?; let result = self.publish_policy_transition(¤t, &next_policy, &next_key, next.clone())?; - write_client_state( + write_client_state_locked( pin_path, &HashSet::new(), &result.0, @@ -111,6 +237,7 @@ impl EncryptedRepository { } pub fn revoke_device(&self, device_id: &str, pin_path: &Path) -> Result<(String, String)> { + let _pin_lock = ClientStateLock::acquire(pin_path)?; let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?.clone(); self.validate_pinned_head(&chain, &read_client_state(pin_path)?)?; @@ -139,7 +266,7 @@ impl EncryptedRepository { let next = transition_manifest(¤t, &next_policy, policy_bytes, &next_key)?; let result = self.publish_policy_transition(¤t, &next_policy, &next_key, next.clone())?; - write_client_state( + write_client_state_locked( pin_path, &HashSet::new(), &result.0, @@ -150,11 +277,12 @@ impl EncryptedRepository { } pub fn pin_admin_state(&self, pin_path: &Path) -> Result<()> { + let _pin_lock = ClientStateLock::acquire(pin_path)?; let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?; let previous = read_client_state(pin_path)?; self.validate_pinned_head(&chain, &previous)?; - write_client_state( + write_client_state_locked( pin_path, &HashSet::new(), ¤t.id, @@ -257,8 +385,21 @@ impl EncryptedRepository { destination_ref: &str, force: bool, ) -> Result { - let (current, next_object, non_fast_forward) = - self.preflight_ref_change(repo, remote_name, source_ref, destination_ref, force)?; + let state_path = remote_name + .map(|name| client_state_path(repo, name)) + .transpose()?; + let _state_lock = state_path + .as_deref() + .map(ClientStateLock::acquire) + .transpose()?; + let (current, next_object, non_fast_forward) = self.preflight_ref_change( + repo, + remote_name, + state_path.as_deref(), + source_ref, + destination_ref, + force, + )?; if next_object .as_ref() .is_some_and(|next| current.manifest.refs.get(destination_ref) == Some(next)) @@ -342,8 +483,8 @@ impl EncryptedRepository { ManifestAuthorization::Writer, next.clone(), )?; - if let Some(remote_name) = remote_name { - self.pin_manifest(repo, remote_name, &next_id, &next)?; + if let Some(state_path) = state_path.as_deref() { + self.pin_manifest_locked(state_path, &next_id, &next)?; } Ok(next_id) } @@ -355,7 +496,7 @@ impl EncryptedRepository { destination_ref: &str, force: bool, ) -> Result<()> { - self.preflight_ref_change(repo, None, Some(source_ref), destination_ref, force)?; + self.preflight_ref_change(repo, None, None, Some(source_ref), destination_ref, force)?; Ok(()) } @@ -367,9 +508,12 @@ impl EncryptedRepository { destination_ref: &str, force: bool, ) -> Result<()> { + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; self.preflight_ref_change( repo, Some(remote_name), + Some(&state_path), Some(source_ref), destination_ref, force, @@ -383,7 +527,16 @@ impl EncryptedRepository { remote_name: &str, destination_ref: &str, ) -> Result<()> { - self.preflight_ref_change(repo, Some(remote_name), None, destination_ref, false)?; + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; + self.preflight_ref_change( + repo, + Some(remote_name), + Some(&state_path), + None, + destination_ref, + false, + )?; Ok(()) } @@ -391,6 +544,7 @@ impl EncryptedRepository { &self, repo: &Path, remote_name: Option<&str>, + state_path: Option<&Path>, source_ref: Option<&str>, destination_ref: &str, force: bool, @@ -411,8 +565,9 @@ impl EncryptedRepository { } bail!("device is not an active writer") } - if let Some(remote_name) = remote_name { - self.validate_remote_continuity(repo, remote_name, &chain)?; + if remote_name.is_some() { + let state_path = state_path.context("remote client state path is missing")?; + self.validate_remote_continuity(state_path, &chain)?; } for reference in current.manifest.refs.keys() { git::validate_inner_ref(repo, reference)?; @@ -457,7 +612,11 @@ impl EncryptedRepository { } pub fn fetch_into(&self, repo: &Path, remote_name: &str) -> Result { - let manifest = self.import_packs(repo, remote_name)?; + git::ensure_repository(repo)?; + validate_remote_name(remote_name)?; + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; + let manifest = self.import_packs_locked(repo, &state_path)?; for (reference, object) in &manifest.refs { match git::validate_inner_ref(repo, reference)? { git::InnerRefKind::Branch => { @@ -473,28 +632,52 @@ impl EncryptedRepository { pub fn observe_manifest(&self, repo: &Path, remote_name: &str) -> Result { git::ensure_repository(repo)?; validate_remote_name(remote_name)?; + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?.clone(); for reference in current.manifest.refs.keys() { git::validate_inner_ref(repo, reference)?; } - let state_path = client_state_path(repo, remote_name)?; let state = read_client_state(&state_path)?; self.validate_pinned_head(&chain, &state)?; - write_observed_client_state(&state_path, state, ¤t.id, ¤t.manifest)?; + // Git skips the helper's fetch command when every advertised object is + // already local, for example after a membership change, a deletion, or + // a tag that points at an existing object. Advance the floor here only + // when the advertised refs are fully connected locally, which is the + // same check a fetch would finish with; authentication alone never + // moves it. Earlier packs are not needed once these refs are + // connected, because later packs are built against these tips. + if git::ensure_refs_connected_since(repo, ¤t.manifest.refs, &state.verified_refs) + .is_ok() + { + let packs: HashSet = state.packs.iter().cloned().collect(); + write_client_state_locked( + &state_path, + &packs, + ¤t.id, + ¤t.manifest, + ¤t.manifest.refs, + )?; + } Ok(current.manifest.clone()) } pub fn import_packs(&self, repo: &Path, remote_name: &str) -> Result { git::ensure_repository(repo)?; validate_remote_name(remote_name)?; + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; + self.import_packs_locked(repo, &state_path) + } + + fn import_packs_locked(&self, repo: &Path, state_path: &Path) -> Result { let chain = self.current_chain()?; let current = chain.first().context("manifest chain is empty")?.clone(); for reference in current.manifest.refs.keys() { git::validate_inner_ref(repo, reference)?; } - let state_path = client_state_path(repo, remote_name)?; - let state = read_client_state(&state_path)?; + let state = read_client_state(state_path)?; self.validate_pinned_head(&chain, &state)?; let stop_generation = state.imported_generation; @@ -536,8 +719,8 @@ impl EncryptedRepository { } } git::ensure_refs_connected_since(repo, ¤t.manifest.refs, &state.verified_refs)?; - write_client_state( - &state_path, + write_client_state_locked( + state_path, &imported, ¤t.id, ¤t.manifest, @@ -546,26 +729,19 @@ impl EncryptedRepository { Ok(current.manifest) } - fn validate_remote_continuity( - &self, - repo: &Path, - remote_name: &str, - chain: &[OpenedState], - ) -> Result<()> { - let state = read_client_state(&client_state_path(repo, remote_name)?)?; + fn validate_remote_continuity(&self, state_path: &Path, chain: &[OpenedState]) -> Result<()> { + let state = read_client_state(state_path)?; self.validate_pinned_head(chain, &state) } - fn pin_manifest( + fn pin_manifest_locked( &self, - repo: &Path, - remote_name: &str, + state_path: &Path, head_id: &str, manifest: &Manifest, ) -> Result<()> { - let state_path = client_state_path(repo, remote_name)?; - let state = read_client_state(&state_path)?; - write_observed_client_state(&state_path, state, head_id, manifest) + let state = read_client_state(state_path)?; + write_published_client_state_locked(state_path, state, head_id, manifest) } fn validate_pinned_head(&self, chain: &[OpenedState], state: &ClientState) -> Result<()> { @@ -645,12 +821,602 @@ impl EncryptedRepository { Ok((current.id, current.manifest)) } + pub fn recover( + &self, + repo: &Path, + remote_name: &str, + options: RecoveryOptions<'_>, + ) -> Result { + git::ensure_repository(repo)?; + validate_remote_name(remote_name)?; + let state_path = client_state_path(repo, remote_name)?; + let _state_lock = ClientStateLock::acquire(&state_path)?; + let state = read_client_state(&state_path)?; + let mut report = RecoveryReport { + classification: RecoveryClass::Unavailable, + reason: "could not read the storage head".to_owned(), + head_id: None, + claimed_signer: None, + floor_id: state.head_id.clone(), + floor_generation: state.generation, + candidates: Vec::new(), + default_base: None, + replays: Vec::new(), + conflict: false, + freshness_unverified: state.head_id.is_none() || state.generation.is_none(), + warning: None, + blocked_reason: None, + published_manifest: None, + }; + let observation = match self.storage.observe_head() { + Ok(observation) => observation, + Err(error) => { + if format!("{error:#}").contains("budget exhausted") { + return Err(error).context("bounded recovery discovery failed"); + } + report.reason = format!("storage head is unavailable: {error:#}"); + if options.publish { + report.blocked_reason = Some(report.reason.clone()); + } + return Ok(report); + } + }; + report.head_id = observation.head_id.clone(); + let mut budget = RecoveryBudget { + crypto_operations: 0, + bytes_read: 0, + }; + let current_chain = match observation.head_id.as_deref() { + Some(head) => self.chain_from( + head, + &mut |id| self.read_manifest_material(id), + Some(&mut budget), + ), + 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")), + }; + match current_chain { + Ok(chain) => { + report.claimed_signer = chain.first().map(|entry| entry.verified_signer.clone()); + if state.head_id.is_some() && state.generation.is_some() { + match self.validate_pinned_head(&chain, &state) { + Ok(()) => match self.check_chain_ciphertext_presence(&chain) { + Ok(()) => { + report.classification = RecoveryClass::Valid; + report.reason = + "storage head is authenticated and continuous with the local floor" + .to_owned(); + } + Err(error) => { + report.classification = RecoveryClass::Unavailable; + report.reason = format!( + "authenticated storage head has unavailable ciphertext: {error:#}" + ); + } + }, + Err(error) => { + report.classification = RecoveryClass::Discontinuous; + report.reason = + format!("authenticated storage head is discontinuous: {error:#}"); + } + } + } else { + match self.check_chain_ciphertext_presence(&chain) { + Ok(()) => { + report.classification = RecoveryClass::Valid; + report.reason = + "storage head is authenticated, but this client has no continuity floor" + .to_owned(); + } + Err(error) => { + report.classification = RecoveryClass::Unavailable; + report.reason = format!( + "authenticated storage head has unavailable ciphertext: {error:#}" + ); + } + } + report.freshness_unverified = true; + } + } + Err(error) => { + if format!("{error:#}").contains("budget exhausted") { + return Err(error).context("bounded recovery discovery failed"); + } + let (class, reason) = + classify_recovery_error(&error, observation.head_bytes.as_deref()); + report.classification = class; + report.reason = reason; + if class == RecoveryClass::Invalid + && let Some(head) = observation.head_id.as_deref() + { + report.claimed_signer = self.claimed_signer_for_head(head).ok(); + } + } + } + + if report.classification == RecoveryClass::Invalid { + let evidence_id = observation.head_id.clone().unwrap_or_else(|| { + hex::encode(Sha256::digest( + observation.head_bytes.as_deref().unwrap_or_default(), + )) + }); + record_rejected_head( + &state_path, + &evidence_id, + report.classification, + &report.reason, + )?; + } else { + report.blocked_reason = Some(match report.classification { + RecoveryClass::Valid => { + "the storage head is already valid; recovery is not needed".to_owned() + } + RecoveryClass::Unverifiable => { + "the storage head cannot be opened by this device; recovery is forbidden" + .to_owned() + } + RecoveryClass::Unsupported => { + "the storage head uses an unsupported format; recovery is forbidden".to_owned() + } + RecoveryClass::Unavailable => { + "required storage data is unavailable; recovery is forbidden".to_owned() + } + RecoveryClass::Discontinuous => { + "the storage head is discontinuous with the local floor; recovery is forbidden" + .to_owned() + } + RecoveryClass::Invalid => unreachable!(), + }); + return Ok(report); + } + + let (Some(floor_id), Some(floor_generation)) = (state.head_id.as_deref(), state.generation) + else { + report.freshness_unverified = true; + report.blocked_reason = Some( + "this repository has no authenticated continuity floor; freshness and fork identity are unverified".to_owned(), + ); + return Ok(report); + }; + + let history = self + .storage + .recovery_history(MAX_RECOVERY_OUTER_COMMITS, MAX_RECOVERY_MANIFEST_BYTES)?; + 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") + } + let historical_bytes = history.manifests.values().try_fold(0_u64, |total, bytes| { + total + .checked_add(bytes.len() as u64) + .context("recovery manifest byte budget overflow") + })?; + if historical_bytes > MAX_RECOVERY_MANIFEST_BYTES { + bail!("recovery manifest byte budget exhausted") + } + 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") + } + if carrier_history + && !history + .commits + .iter() + .any(|commit| commit.head_id.as_deref() == Some(floor_id)) + { + report.blocked_reason = Some(format!( + "carrier history does not contain the pinned floor manifest {floor_id}; refusing discontinuous recovery" + )); + report.classification = RecoveryClass::Discontinuous; + return Ok(report); + } + + let read_history_material = |id: &str| -> Result<(String, Vec, ManifestHeader)> { + 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:#})") + })?; + if object_id(bytes) != id { + bail!("historical manifest {id} content ID mismatch") + } + let header = peek_manifest_header(bytes)?; + Ok((id.to_owned(), bytes.clone(), header)) + } + } + }; + let floor_chain = match self.chain_from( + floor_id, + &mut |id| read_history_material(id), + Some(&mut budget), + ) { + Ok(chain) => chain, + Err(error) => { + report.blocked_reason = Some(format!( + "pinned floor is unavailable or cannot be authenticated: {error:#}" + )); + return Ok(report); + } + }; + let floor = match floor_chain + .iter() + .find(|entry| entry.id == floor_id && entry.manifest.generation == floor_generation) + { + Some(floor) => floor, + None => { + report.blocked_reason = Some( + "client state floor does not match its authenticated manifest generation" + .to_owned(), + ); + return Ok(report); + } + }; + if state + .repository_root + .as_deref() + .is_some_and(|root| root != floor.manifest.repository_root) + || state + .policy_generation + .is_some_and(|generation| generation > floor.manifest.policy_generation) + { + report.blocked_reason = Some( + "client state floor metadata does not match the authenticated floor".to_owned(), + ); + return Ok(report); + } + let floor_candidate = RecoveryCandidate { + manifest_id: floor.id.clone(), + generation: floor.manifest.generation, + outer_commit: history + .commits + .iter() + .find(|commit| commit.head_id.as_deref() == Some(floor_id)) + .map(|commit| commit.commit_id.clone()), + verified_signer: floor.verified_signer.clone(), + }; + + let mut replay_ids = BTreeSet::new(); + let mut seen_head_ids = HashSet::new(); + let mut descendants: Vec<(RecoveryCandidate, Vec)> = Vec::new(); + if carrier_history { + let mut unique_heads = Vec::new(); + for commit in &history.commits { + if let Some(head) = &commit.head_id { + if seen_head_ids.insert(head.clone()) { + unique_heads.push((head, commit)); + } else { + replay_ids.insert(head.clone()); + } + } + } + if unique_heads.len() > MAX_RECOVERY_CANDIDATES { + bail!("recovery candidate budget exhausted") + } + for (candidate_id, commit) in unique_heads { + if candidate_id == floor_id { + 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, None); + 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, + } + } + }; + 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 + }); + if floor_position.is_none() { + report.blocked_reason = Some(format!( + "authenticated historical head {candidate_id} does not descend from the pinned floor" + )); + return Ok(report); + } + descendants.push(( + RecoveryCandidate { + manifest_id: candidate.id.clone(), + generation: candidate.manifest.generation, + outer_commit: Some(commit.commit_id.clone()), + verified_signer: candidate.verified_signer.clone(), + }, + candidate_chain, + )); + } + for (index, (left, left_chain)) in descendants.iter().enumerate() { + for (right, right_chain) in descendants.iter().skip(index + 1) { + let left_is_ancestor = + right_chain.iter().any(|entry| entry.id == left.manifest_id); + let right_is_ancestor = + left_chain.iter().any(|entry| entry.id == right.manifest_id); + if !left_is_ancestor && !right_is_ancestor { + report.conflict = true; + } + } + } + } + if report.conflict { + report.blocked_reason = Some( + "authenticated descendants conflict; refusing to choose between forks".to_owned(), + ); + report.candidates.push(floor_candidate); + report + .candidates + .extend(descendants.into_iter().map(|(candidate, _)| candidate)); + report.replays = replay_ids.into_iter().collect(); + return Ok(report); + } + + let mut offers = vec![(floor_candidate.clone(), floor_chain.clone())]; + offers.extend(descendants); + offers.sort_by_key(|(candidate, _)| candidate.generation); + report.candidates = offers + .iter() + .map(|(candidate, _)| candidate.clone()) + .collect(); + report.replays = replay_ids.into_iter().collect(); + let default_offer = offers.last().context("recovery floor offer is missing")?; + report.default_base = Some(default_offer.0.manifest_id.clone()); + if !carrier_history { + report.warning = Some(STALE_FLOOR_WARNING.to_owned()); + } + if !options.publish { + return Ok(report); + } + + let selected_id = options.base.unwrap_or(&default_offer.0.manifest_id); + let selected = match offers + .iter() + .find(|(candidate, _)| candidate.manifest_id == selected_id) + { + Some(selected) => selected, + None => { + report.blocked_reason = Some(format!( + "selected base {selected_id} is not an authenticated recovery offer" + )); + return Ok(report); + } + }; + if selected.0.generation < default_offer.0.generation && !options.discard_newer { + report.blocked_reason = Some(format!( + "choosing older base {} requires --discard-newer", + selected.0.manifest_id + )); + return Ok(report); + } + if !carrier_history && !options.accept_stale_floor { + report.blocked_reason = Some(format!( + "directory recovery requires --accept-stale-floor. {}", + STALE_FLOOR_WARNING + )); + return Ok(report); + } + let base_state = selected + .1 + .first() + .filter(|entry| entry.id == selected.0.manifest_id) + .context("selected recovery base is missing from its validated chain")?; + if !base_state.policy.is_active_writer(&self.key.device_id()?) { + report.blocked_reason = + Some("device is not an active writer under the selected base policy".to_owned()); + return Ok(report); + } + if let Err(error) = self.restore_base_objects(&selected.1) { + report.blocked_reason = Some(format!("required ciphertext is unavailable: {error:#}")); + return Ok(report); + } + if let Err(error) = self.verify_base_graph(&selected.1) { + report.blocked_reason = Some(format!( + "selected base does not resolve to a complete local object graph: {error:#}" + )); + return Ok(report); + } + let required_manifests: Vec<_> = selected.1.iter().map(|entry| entry.id.clone()).collect(); + let required_packs: Vec<_> = selected + .1 + .iter() + .flat_map(|entry| entry.manifest.new_packs.iter().map(|pack| pack.id.clone())) + .collect(); + self.storage + .prepare_recovery(&required_manifests, &required_packs)?; + + let next_key = random_key(); + let generation = base_state.manifest.generation + 1; + let next = Manifest { + format_version: base_state.manifest.format_version, + repository_root: base_state.manifest.repository_root.clone(), + generation, + previous: Some(base_state.id.clone()), + policy_id: base_state.policy.id.clone(), + policy_generation: base_state.policy.body.generation, + authorization: ManifestAuthorization::Writer, + total_pack_count: base_state.manifest.total_pack_count, + refs: base_state.manifest.refs.clone(), + new_packs: Vec::new(), + predecessor_key_wrap: Some(wrap_predecessor_key( + &next_key, + &base_state.generation_key, + &base_state.manifest.repository_root, + generation, + &base_state.id, + )?), + introduced_policy: None, + }; + let recovered_id = self.commit_manifest_with_observation( + CommitHead { + previous: Some(base_state), + observation: Some(&observation), + }, + &base_state.policy, + None, + &next_key, + ManifestAuthorization::Writer, + next.clone(), + )?; + self.pin_manifest_locked(&state_path, &recovered_id, &next)?; + report.published_manifest = Some(recovered_id); + report.blocked_reason = None; + Ok(report) + } + + fn claimed_signer_for_head(&self, head: &str) -> Result { + let (_, encrypted, header) = self.read_manifest_material(head)?; + let key = unwrap_generation_key(&header, &self.key)?; + Ok(open_manifest(&encrypted, &key)?.claimed_signer_id) + } + + fn check_chain_ciphertext_presence(&self, chain: &[OpenedState]) -> Result<()> { + for state in chain { + for descriptor in &state.manifest.new_packs { + self.storage + .open_object(ObjectKind::Pack, &descriptor.id) + .with_context(|| format!("required pack {} is unavailable", descriptor.id))?; + } + } + Ok(()) + } + + fn restore_base_objects(&self, chain: &[OpenedState]) -> Result<()> { + for state in chain { + self.ensure_object_available(ObjectKind::Manifest, &state.id)?; + for descriptor in &state.manifest.new_packs { + self.ensure_object_available(ObjectKind::Pack, &descriptor.id)?; + } + } + Ok(()) + } + + fn verify_base_graph(&self, chain: &[OpenedState]) -> Result<()> { + let temporary = tempfile::tempdir()?; + let init = std::process::Command::new("git") + .arg("init") + .arg("--bare") + .arg("--quiet") + .arg(temporary.path()) + .output()?; + if !init.status.success() { + bail!( + "git init for recovery connectivity check failed: {}", + String::from_utf8_lossy(&init.stderr).trim() + ) + } + for entry in chain.iter().rev() { + for descriptor in &entry.manifest.new_packs { + let encrypted = self.storage.open_object(ObjectKind::Pack, &descriptor.id)?; + let pack_key = derive_subkey( + &entry.generation_key, + &entry.manifest.repository_root, + descriptor.generation, + SubkeyKind::Pack, + descriptor.ordinal, + )?; + let mut importer = git::start_pack_import(temporary.path())?; + open_pack_stream( + &pack_key, + encrypted, + &mut importer, + &pack_aad( + &entry.manifest.repository_root, + descriptor.generation, + descriptor.ordinal, + ), + descriptor.plaintext_size, + &descriptor.id, + )?; + importer.finish()?; + } + } + git::ensure_refs_connected_since( + temporary.path(), + &chain + .first() + .context("selected recovery chain is empty")? + .manifest + .refs, + &BTreeMap::new(), + ) + } + + fn ensure_object_available(&self, kind: ObjectKind, id: &str) -> Result<()> { + if self.storage_object_matches(kind, id)? { + return Ok(()); + } + if self.storage.restore_historical_object(kind, id)? + && self.storage_object_matches(kind, id)? + { + return Ok(()); + } + bail!("required {:?} object {id} is missing or corrupt", kind) + } + + fn storage_object_matches(&self, kind: ObjectKind, id: &str) -> Result { + let mut reader = match self.storage.open_object(kind, id) { + Ok(reader) => reader, + Err(_) => return Ok(false), + }; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize()) == id) + } + fn current_chain(&self) -> Result> { let head = self .storage .read_head()? .context("encrypted repository is not initialized")?; - let (mut id, mut encrypted, mut header) = self.read_manifest_material(&head)?; + self.chain_from(&head, &mut |id| self.read_manifest_material(id), None) + } + + fn chain_from( + &self, + head: &str, + read_material: &mut F, + mut budget: Option<&mut RecoveryBudget>, + ) -> Result> + where + F: FnMut(&str) -> Result<(String, Vec, ManifestHeader)>, + { + let (mut id, mut encrypted, mut header) = read_material(head)?; + if let Some(budget) = budget.as_deref_mut() { + 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 unverified = Vec::new(); @@ -667,8 +1433,10 @@ impl EncryptedRepository { unverified.push((id, opened, generation_key)); break; }; - let (parent_id, parent_encrypted, parent_header) = - self.read_manifest_material(&previous_id)?; + let (parent_id, parent_encrypted, parent_header) = read_material(&previous_id)?; + if let Some(budget) = budget.as_deref_mut() { + budget.charge_manifest(&parent_header, parent_encrypted.len())?; + } let parent_key = unwrap_predecessor_key(&generation_key, &opened.manifest, &parent_header)?; unverified.push((id, opened, generation_key)); @@ -728,12 +1496,14 @@ impl EncryptedRepository { (policy, parent_policy) }; verify_manifest(&opened, &policy, parent_policy.as_ref(), &generation_key)?; + let verified_signer = opened.claimed_signer_id.clone(); chain_oldest_first.push(OpenedState { id, manifest: opened.manifest, header: opened.header, policy, generation_key, + verified_signer, }); } chain_oldest_first.reverse(); @@ -758,7 +1528,29 @@ impl EncryptedRepository { authorization: ManifestAuthorization, manifest: Manifest, ) -> Result { - match previous { + self.commit_manifest_with_observation( + CommitHead { + previous, + observation: None, + }, + policy, + parent_policy, + generation_key, + authorization, + manifest, + ) + } + + fn commit_manifest_with_observation( + &self, + head: CommitHead<'_>, + policy: &PolicyState, + parent_policy: Option<&PolicyState>, + generation_key: &[u8; 32], + authorization: ManifestAuthorization, + manifest: Manifest, + ) -> Result { + match head.previous { Some(previous) => { manifest.validate_successor(&previous.id, &previous.manifest, policy)? } @@ -777,7 +1569,7 @@ impl EncryptedRepository { let opened = open_manifest(&encrypted, generation_key)?; verify_manifest(&opened, policy, parent_policy, generation_key)?; - if let Some(previous) = previous { + if let Some(previous) = head.previous { let recovered = unwrap_predecessor_key(generation_key, &opened.manifest, &previous.header)?; if *recovered != *previous.generation_key { @@ -787,8 +1579,13 @@ impl EncryptedRepository { if opened.header.generation != manifest.generation { bail!("new manifest self-check changed generation") } - self.storage - .compare_and_swap_head(previous.map(|state| state.id.as_str()), &id)?; + if let Some(observation) = head.observation { + self.storage + .compare_and_swap_observed_head(observation, &id)?; + } else { + self.storage + .compare_and_swap_head(head.previous.map(|state| state.id.as_str()), &id)?; + } Ok(id) } } @@ -822,6 +1619,89 @@ fn transition_manifest( }) } +#[derive(Clone, Debug, Serialize, Deserialize)] +struct RejectedHeadEvidence { + head_id: String, + classification: RecoveryClass, + reason: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct RejectedHeadLog { + format_version: u32, + 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) + }) { + return (RecoveryClass::Unavailable, detail); + } + if detail.contains("encrypted repository has no storage HEAD") && raw_head.is_none() { + return (RecoveryClass::Unavailable, detail); + } + (RecoveryClass::Invalid, detail) +} + +fn record_rejected_head( + state_path: &Path, + head_id: &str, + classification: RecoveryClass, + reason: &str, +) -> Result<()> { + let directory = state_path + .parent() + .context("client state path has no parent")?; + let path = directory.join("rejected-heads.json"); + let mut log = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes).unwrap_or_default(), + Err(error) if error.kind() == io::ErrorKind::NotFound => RejectedHeadLog::default(), + Err(error) => return Err(error.into()), + }; + log.format_version = 1; + log.entries.retain(|entry| entry.head_id != head_id); + let reason: String = reason.chars().take(512).collect(); + log.entries.push(RejectedHeadEvidence { + head_id: head_id.to_owned(), + classification, + reason, + }); + if log.entries.len() > MAX_REJECTED_HEADS { + let remove = log.entries.len() - MAX_REJECTED_HEADS; + log.entries.drain(..remove); + } + let mut random = [0_u8; 8]; + OsRng.fill_bytes(&mut random); + let temporary = directory.join(format!(".rejected-heads-{}.tmp", hex::encode(random))); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&serde_json::to_vec_pretty(&log)?)?; + file.sync_all()?; + fs::rename(&temporary, &path)?; + crate::persist::sync_directory(directory)?; + Ok(()) +} + fn git_state_directory(repo: &Path) -> Result { let output = std::process::Command::new("git") .arg("-C") @@ -862,7 +1742,7 @@ fn read_client_state(path: &Path) -> Result { } } -fn write_client_state( +fn write_client_state_locked( path: &Path, values: &HashSet, head_id: &str, @@ -881,10 +1761,10 @@ fn write_client_state( repository_root: Some(manifest.repository_root.clone()), verified_refs: verified_refs.clone(), }; - persist_client_state(path, &state) + persist_client_state_locked(path, &state) } -fn write_observed_client_state( +fn write_published_client_state_locked( path: &Path, previous: ClientState, head_id: &str, @@ -900,9 +1780,85 @@ fn write_observed_client_state( repository_root: Some(manifest.repository_root.clone()), verified_refs: previous.verified_refs, }; + persist_client_state_locked(path, &state) +} + +fn persist_client_state_locked(path: &Path, state: &ClientState) -> Result<()> { + let previous = read_client_state(path)?; + let state = merge_client_state(previous, state.clone())?; persist_client_state(path, &state) } +fn merge_client_state(previous: ClientState, mut next: ClientState) -> Result { + if previous.format_version != 5 { + return Ok(next); + } + if next.generation.is_some() + && (next.head_id.is_none() + || next.policy_generation.is_none() + || next.repository_root.is_none()) + { + bail!("client state update is missing a continuity pin field") + } + if let (Some(old_root), Some(new_root)) = (&previous.repository_root, &next.repository_root) + && old_root != new_root + { + bail!("remote repository root changed") + } + if previous.generation.is_some() && next.generation.is_none() { + return Ok(previous); + } + + if let (Some(old_generation), Some(new_generation)) = (previous.generation, next.generation) { + if old_generation > new_generation { + return Ok(previous); + } + if old_generation == new_generation { + if let (Some(old_id), Some(new_id)) = (&previous.head_id, &next.head_id) + && old_id != new_id + { + bail!("remote manifest history forked from the locally pinned head") + } + if let (Some(old_policy), Some(new_policy)) = + (previous.policy_generation, next.policy_generation) + && old_policy != new_policy + { + bail!("remote policy pin changed within the same manifest generation") + } + } + } + if let (Some(old_policy), Some(new_policy)) = + (previous.policy_generation, next.policy_generation) + && new_policy < old_policy + { + bail!("remote policy rolled back") + } + + if previous.repository_root.is_some() { + next.repository_root = previous.repository_root.clone(); + } + if next.head_id.is_none() { + next.head_id = previous.head_id.clone(); + } + if previous.policy_generation.is_some() { + next.policy_generation = previous.policy_generation.max(next.policy_generation); + } + if previous + .imported_generation + .is_some_and(|old| next.imported_generation.is_none_or(|new| new < old)) + { + next.imported_generation = previous.imported_generation; + next.verified_refs = previous.verified_refs; + } + + let mut packs = previous.packs; + packs.extend(next.packs); + packs.sort(); + packs.dedup(); + next.packs = packs; + Ok(next) +} + fn persist_client_state(path: &Path, state: &ClientState) -> Result<()> { let mut random = [0_u8; 8]; OsRng.fill_bytes(&mut random); @@ -947,7 +1903,8 @@ pub(crate) fn testing_write_client_state( repository_root: Some("durability-fixture".to_owned()), verified_refs: BTreeMap::new(), }; - persist_client_state(path, &state) + let _state_lock = ClientStateLock::acquire(path)?; + persist_client_state_locked(path, &state) } fn validate_remote_name(name: &str) -> Result<()> { @@ -963,7 +1920,99 @@ fn validate_remote_name(name: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::ClientState; + use std::env; + use std::fs; + use std::path::Path; + use std::process::{Child, Command}; + use std::thread; + use std::time::{Duration, Instant}; + + use super::{ClientState, read_client_state, testing_write_client_state}; + + #[test] + fn concurrent_client_state_process_writes_never_lower_or_corrupt_the_floor() { + let temporary = tempfile::tempdir().unwrap(); + let state_path = temporary.path().join("state.json"); + testing_write_client_state(&state_path, "head-10", 10).unwrap(); + let release = temporary.path().join("release-old-writer"); + let ready = temporary.path().join("old-writer-ready"); + let executable = env::current_exe().unwrap(); + + let older = Command::new(&executable) + .args([ + "--exact", + "repository::tests::client_state_process_write_worker", + "--nocapture", + ]) + .env("E2EE_TEST_CLIENT_STATE_PATH", &state_path) + .env("E2EE_TEST_CLIENT_STATE_GENERATION", "11") + .env("E2EE_TEST_CLIENT_STATE_READY", &ready) + .env("E2EE_TEST_CLIENT_STATE_RELEASE", &release) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.exists(), "older writer did not reach its wait point"); + + let newer = launch_client_state_writer(&executable, &state_path, 12); + let newer_output = newer.wait_with_output().unwrap(); + assert!( + newer_output.status.success(), + "newer writer failed: {}", + String::from_utf8_lossy(&newer_output.stderr) + ); + fs::write(&release, "continue").unwrap(); + + let older_output = older.wait_with_output().unwrap(); + assert!( + older_output.status.success(), + "older writer failed: {}", + String::from_utf8_lossy(&older_output.stderr) + ); + let state = read_client_state(&state_path).unwrap(); + assert_eq!(state.generation, Some(12)); + assert_eq!(state.head_id.as_deref(), Some("head-12")); + } + + #[test] + fn client_state_process_write_worker() { + let Ok(path) = env::var("E2EE_TEST_CLIENT_STATE_PATH") else { + return; + }; + let generation = env::var("E2EE_TEST_CLIENT_STATE_GENERATION") + .unwrap() + .parse::() + .unwrap(); + if let (Ok(ready), Ok(release)) = ( + env::var("E2EE_TEST_CLIENT_STATE_READY"), + env::var("E2EE_TEST_CLIENT_STATE_RELEASE"), + ) { + fs::write(&ready, "ready").unwrap(); + let release = Path::new(&release); + let deadline = Instant::now() + Duration::from_secs(10); + while !release.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(release.exists(), "parent did not release the older writer"); + } + testing_write_client_state(Path::new(&path), &format!("head-{generation}"), generation) + .unwrap(); + } + + fn launch_client_state_writer(executable: &Path, state_path: &Path, generation: u64) -> Child { + Command::new(executable) + .args([ + "--exact", + "repository::tests::client_state_process_write_worker", + "--nocapture", + ]) + .env("E2EE_TEST_CLIENT_STATE_PATH", state_path) + .env("E2EE_TEST_CLIENT_STATE_GENERATION", generation.to_string()) + .spawn() + .unwrap() + } #[test] fn client_state_without_verified_frontier_requires_a_full_check() { diff --git a/src/storage.rs b/src/storage.rs index 02dfff3..f72669d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -11,6 +12,8 @@ use rand::rngs::OsRng; use sha2::{Digest, Sha256}; use thiserror::Error; +use crate::crypto::object_id; + #[derive(Debug, Error)] #[error("head changed concurrently (expected {expected:?}, actual {actual:?})")] pub struct CasConflict { @@ -19,6 +22,7 @@ pub struct CasConflict { } const MAX_BUFFERED_OBJECT_SIZE: u64 = 16 * 1024 * 1024; +const MAX_STORAGE_HEAD_BYTES: u64 = 4096; pub trait ObjectStage: Write + Send { fn finish(self: Box, id: &str) -> Result<()>; @@ -89,6 +93,55 @@ pub trait Storage { fn read_head(&self) -> Result>; fn compare_and_swap_head(&self, expected: Option<&str>, next: &str) -> Result<()>; + + fn observe_head(&self) -> Result { + let head_id = self.read_head()?; + let bytes = head_id.as_deref().unwrap_or_default().as_bytes().to_vec(); + Ok(HeadObservation { + head_id, + token: bytes.clone(), + head_bytes: Some(bytes), + }) + } + + fn compare_and_swap_observed_head(&self, observed: &HeadObservation, next: &str) -> Result<()> { + self.compare_and_swap_head(observed.head_id.as_deref(), next) + } + + fn recovery_history( + &self, + _max_commits: usize, + _max_manifest_bytes: u64, + ) -> Result> { + Ok(None) + } + + fn restore_historical_object(&self, _kind: ObjectKind, _id: &str) -> Result { + Ok(false) + } + + fn prepare_recovery(&self, _manifest_ids: &[String], _pack_ids: &[String]) -> Result<()> { + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct HeadObservation { + pub head_id: Option, + pub token: Vec, + pub head_bytes: Option>, +} + +#[derive(Clone, Debug)] +pub struct RecoveryCommit { + pub commit_id: String, + pub head_id: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct RecoveryHistory { + pub commits: Vec, + pub manifests: std::collections::BTreeMap>, } #[derive(Clone, Copy, Debug)] @@ -275,6 +328,87 @@ impl Storage for FilesystemStorage { fs2::FileExt::unlock(&lock)?; Ok(()) } + + fn observe_head(&self) -> Result { + let head_bytes = read_bounded_head_file(&self.root.join("HEAD"))?; + let head_id = head_bytes.as_deref().and_then(|bytes| { + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|value| validate_id(value).is_ok()) + .map(ToOwned::to_owned) + }); + Ok(HeadObservation { + head_id, + token: head_bytes.clone().unwrap_or_default(), + head_bytes, + }) + } + + fn compare_and_swap_observed_head(&self, observed: &HeadObservation, next: &str) -> Result<()> { + validate_id(next)?; + let lock = self.lock_file()?; + lock.lock_exclusive()?; + let actual = match fs::read(self.root.join("HEAD")) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + fs2::FileExt::unlock(&lock)?; + return Err(error.into()); + } + }; + if actual != observed.head_bytes { + let actual_id = actual.as_deref().and_then(|bytes| { + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|value| validate_id(value).is_ok()) + .map(ToOwned::to_owned) + }); + fs2::FileExt::unlock(&lock)?; + return Err(CasConflict { + expected: observed.head_id.clone(), + actual: actual_id, + } + .into()); + } + + write_filesystem_head_locked(&self.root, next)?; + fs2::FileExt::unlock(&lock)?; + Ok(()) + } +} + +fn write_filesystem_head_locked(root: &Path, next: &str) -> Result<()> { + let mut random = [0_u8; 8]; + OsRng.fill_bytes(&mut random); + let temporary = root.join(format!(".HEAD-{}", hex::encode(random))); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + writeln!(file, "{next}")?; + file.sync_all()?; + crate::persist::durability_checkpoint(crate::persist::STAGE_AFTER_FILE_FLUSH); + fs::rename(&temporary, root.join("HEAD"))?; + crate::persist::durability_checkpoint(crate::persist::STAGE_AFTER_NAME_PUBLISH); + crate::persist::sync_directory(root) + .with_context(|| format!("sync directory {}", root.display()))?; + Ok(()) +} + +fn read_bounded_head_file(path: &Path) -> Result>> { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if file.metadata()?.len() > MAX_STORAGE_HEAD_BYTES { + bail!("recovery outer-head byte budget exhausted") + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(Some(bytes)) } fn validate_id(id: &str) -> Result<()> { @@ -308,6 +442,7 @@ const CARRIER_BRANCH: &str = "git-remote-e2ee"; const CARRIER_CHUNK_SIZE: usize = 32 * 1024 * 1024; const CARRIER_CACHE_ENV: &str = "GIT_REMOTE_E2EE_CACHE_DIR"; const CARRIER_CACHE_FETCH_REF: &str = "refs/heads/git-remote-e2ee"; +const MAX_RECOVERY_OUTER_COMMITS: usize = 2048; pub struct GitStorage { checkout: tempfile::TempDir, @@ -534,6 +669,10 @@ impl GitStorage { } } + fn local_head_bytes(&self) -> Result>> { + read_bounded_head_file(&self.root().join("e2ee/HEAD")) + } + fn refresh_remote(&self) -> Result> { CarrierCache::lock(&self.remote)?.refresh(&self.remote) } @@ -554,6 +693,227 @@ impl GitStorage { validate_id(&value)?; Ok(Some(value)) } + + fn head_bytes_at_commit(&self, commit: &str) -> Result>> { + let object = format!("{commit}:e2ee/HEAD"); + let size = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["cat-file", "-s", &object]) + .output()?; + if !size.status.success() { + return Ok(None); + } + let size = String::from_utf8(size.stdout)?.trim().parse::()?; + if size > MAX_STORAGE_HEAD_BYTES { + bail!("recovery outer-head byte budget exhausted") + } + let output = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["show", &object]) + .output()?; + if output.status.success() { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + fn commit_history(&self, limit: usize) -> Result> { + let maximum = limit.saturating_add(1).to_string(); + let output = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args([ + "rev-list", + "--parents", + &format!("--max-count={maximum}"), + "HEAD", + ]) + .output()?; + ensure_git_success(&output, "git rev-list carrier history")?; + // Every publication is a single-parent fast-forward commit, so + // legitimate carrier history is linear. A merge can only come from a + // storage-level writer and can hide a legitimate state behind a + // non-first parent, so recovery refuses to interpret such history. + let mut commits = Vec::new(); + for line in String::from_utf8(output.stdout)?.lines() { + let mut fields = line.split_ascii_whitespace(); + let commit = fields.next().context("parse carrier history entry")?; + if fields.count() > 1 { + bail!( + "carrier history contains merge commit {commit}; recovery requires linear carrier history and cannot rule out a hidden legitimate state" + ) + } + commits.push(commit.to_owned()); + } + Ok(commits) + } + + fn chunk_entries_at( + &self, + commit: &str, + kind: ObjectKind, + id: &str, + ) -> Result>> { + validate_id(id)?; + let directory = format!("e2ee/{}/{}/{id}", kind.directory(), &id[..2]); + let output = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["ls-tree", "-r", "-z", commit, "--", &directory]) + .output()?; + ensure_git_success(&output, "git ls-tree historical carrier object")?; + if output.stdout.is_empty() { + return Ok(None); + } + if output.stdout.len() > 8 * 1024 * 1024 { + bail!("carrier recovery object listing exceeds its size budget") + } + let mut chunks = Vec::new(); + for entry in output + .stdout + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + { + let tab = entry + .iter() + .position(|byte| *byte == b'\t') + .context("parse historical carrier tree entry")?; + let (metadata, path) = (&entry[..tab], &entry[tab + 1..]); + let metadata = std::str::from_utf8(metadata)?; + let path = std::str::from_utf8(path)?.to_owned(); + let mut fields = metadata.split_ascii_whitespace(); + if fields.next() != Some("100644") || fields.next() != Some("blob") { + bail!("historical carrier object contains a non-regular blob") + } + let blob_id = fields.next().context("historical carrier blob has no id")?; + if fields.next().is_some() || !path.starts_with(&format!("{directory}/")) { + bail!("unexpected historical carrier object path") + } + chunks.push((path, blob_id.to_owned())); + } + chunks.sort_by(|left, right| left.0.cmp(&right.0)); + if chunks.is_empty() || chunks.len() > 4096 { + bail!("historical carrier object has an invalid chunk count") + } + for (index, (path, _)) in chunks.iter().enumerate() { + let expected = format!("{directory}/{index:08}"); + if path != &expected { + bail!("historical carrier object chunks are not a dense canonical sequence") + } + } + Ok(Some(chunks)) + } + + fn object_at_commit( + &self, + commit: &str, + kind: ObjectKind, + id: &str, + max_bytes: u64, + ) -> Result>> { + let Some(chunks) = self.chunk_entries_at(commit, kind, id)? else { + return Ok(None); + }; + let mut bytes = Vec::new(); + for (index, (path, _)) in chunks.iter().enumerate() { + let output = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["show", &format!("{commit}:{path}")]) + .output()?; + ensure_git_success(&output, "git show historical carrier object")?; + let length = output.stdout.len(); + if (index + 1 < chunks.len() && length != CARRIER_CHUNK_SIZE) + || (index + 1 == chunks.len() && (length == 0 || length > CARRIER_CHUNK_SIZE)) + { + bail!("historical carrier object chunk has invalid size") + } + let next_length = (bytes.len() as u64) + .checked_add(length as u64) + .context("historical carrier object size overflow")?; + if next_length > max_bytes { + bail!("carrier recovery manifest byte budget exhausted") + } + bytes.extend_from_slice(&output.stdout); + } + if object_id(&bytes) != id { + bail!("historical carrier object content id mismatch") + } + Ok(Some(bytes)) + } + + fn restore_object_at(&self, commit: &str, kind: ObjectKind, id: &str) -> Result { + let Some(chunks) = self.chunk_entries_at(commit, kind, id)? else { + return Ok(false); + }; + let staging = tempfile::Builder::new() + .prefix("git-remote-e2ee-restore-") + .tempdir_in(self.root())?; + let object_dir = staging.path().join("object"); + fs::create_dir(&object_dir)?; + let mut hasher = Sha256::new(); + for (index, (path, _)) in chunks.iter().enumerate() { + let output = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["show", &format!("{commit}:{path}")]) + .output()?; + ensure_git_success(&output, "git show historical carrier object")?; + let length = output.stdout.len(); + if (index + 1 < chunks.len() && length != CARRIER_CHUNK_SIZE) + || (index + 1 == chunks.len() && (length == 0 || length > CARRIER_CHUNK_SIZE)) + { + bail!("historical carrier object chunk has invalid size") + } + hasher.update(&output.stdout); + let chunk_path = object_dir.join(format!("{index:08}")); + let mut file = File::create(chunk_path)?; + file.write_all(&output.stdout)?; + file.sync_all()?; + } + if hex::encode(hasher.finalize()) != id { + bail!("historical carrier object content id mismatch") + } + let target = self.object_directory(kind, id)?; + if target.exists() { + fs::remove_dir_all(&target)?; + } + fs::create_dir_all( + target + .parent() + .context("carrier object path has no parent")?, + )?; + fs::rename(object_dir, target)?; + Ok(true) + } + + fn publish_checkout_head(&self, next: &str, expected: Option) -> Result<()> { + fs::create_dir_all(self.root().join("e2ee"))?; + fs::write(self.root().join("e2ee/HEAD"), format!("{next}\n"))?; + git_command(self.root(), &["add", "e2ee"])?; + git_command( + self.root(), + &["commit", "--quiet", "-m", "git-remote-e2ee storage update"], + )?; + let push = carrier_git_command() + .arg("-C") + .arg(self.root()) + .args(["push", "--quiet", "origin"]) + .arg(format!("HEAD:refs/heads/{CARRIER_BRANCH}")) + .output()?; + if !push.status.success() { + let latest = self.refresh_remote()?; + return Err(CasConflict { + expected, + actual: self.head_at_commit(latest.as_deref())?, + } + .into()); + } + Ok(()) + } } impl Storage for GitStorage { @@ -636,6 +996,167 @@ impl Storage for GitStorage { state.base_commit = Some(new_commit); Ok(()) } + + fn observe_head(&self) -> Result { + let state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("carrier state lock poisoned"))?; + let head_bytes = self.local_head_bytes()?; + let head_id = head_bytes.as_deref().and_then(|bytes| { + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|value| validate_id(value).is_ok()) + .map(ToOwned::to_owned) + }); + Ok(HeadObservation { + head_id, + token: state + .base_commit + .as_deref() + .unwrap_or_default() + .as_bytes() + .to_vec(), + head_bytes, + }) + } + + fn compare_and_swap_observed_head(&self, observed: &HeadObservation, next: &str) -> Result<()> { + validate_id(next)?; + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("carrier state lock poisoned"))?; + let expected_tip = state.base_commit.clone(); + if observed.token != expected_tip.as_deref().unwrap_or_default().as_bytes() { + bail!("recovery storage token does not match the opened carrier tip") + } + let remote_tip = self.refresh_remote()?; + if remote_tip != expected_tip { + return Err(CasConflict { + expected: observed.head_id.clone(), + actual: self.head_at_commit(remote_tip.as_deref())?, + } + .into()); + } + let actual_bytes = self.local_head_bytes()?; + if actual_bytes != observed.head_bytes { + return Err(CasConflict { + expected: observed.head_id.clone(), + actual: actual_bytes.as_deref().and_then(|bytes| { + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|value| validate_id(value).is_ok()) + .map(ToOwned::to_owned) + }), + } + .into()); + } + self.publish_checkout_head(next, observed.head_id.clone())?; + let new_commit = + git_rev_parse(self.root(), "HEAD")?.context("carrier commit was not created")?; + state.base_commit = Some(new_commit); + Ok(()) + } + + fn recovery_history( + &self, + max_commits: usize, + max_manifest_bytes: u64, + ) -> Result> { + let commits = self.commit_history(max_commits)?; + if commits.len() > max_commits { + bail!("carrier recovery outer-commit budget exhausted") + } + let mut history = RecoveryHistory::default(); + let mut total_bytes = 0_u64; + for commit_id in commits { + let head_id = self + .head_bytes_at_commit(&commit_id)? + .as_deref() + .and_then(|bytes| { + std::str::from_utf8(bytes) + .ok() + .map(str::trim) + .filter(|value| validate_id(value).is_ok()) + .map(ToOwned::to_owned) + }); + if let Some(id) = &head_id + && !history.manifests.contains_key(id) + { + let remaining = max_manifest_bytes.saturating_sub(total_bytes); + if let Some(bytes) = + self.object_at_commit(&commit_id, ObjectKind::Manifest, id, remaining)? + { + total_bytes = total_bytes + .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") + } + history.manifests.insert(id.clone(), bytes); + } + } + history.commits.push(RecoveryCommit { commit_id, head_id }); + } + Ok(Some(history)) + } + + 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") + } + for commit in commits { + if self.restore_object_at(&commit, kind, id)? { + return Ok(true); + } + } + Ok(false) + } + + fn prepare_recovery(&self, manifest_ids: &[String], pack_ids: &[String]) -> Result<()> { + retain_carrier_objects( + &self.root().join("e2ee/manifests"), + &manifest_ids.iter().map(String::as_str).collect(), + )?; + retain_carrier_objects( + &self.root().join("e2ee/objects"), + &pack_ids.iter().map(String::as_str).collect(), + )?; + Ok(()) + } +} + +fn retain_carrier_objects(root: &Path, keep: &HashSet<&str>) -> Result<()> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries.collect::>>()?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for prefix in entries { + if !prefix.file_type()?.is_dir() { + fs::remove_file(prefix.path())?; + continue; + } + let objects = fs::read_dir(prefix.path())?.collect::>>()?; + for object in objects { + let id = object.file_name(); + if id.to_str().is_none_or(|id| !keep.contains(id)) { + if object.file_type()?.is_dir() { + fs::remove_dir_all(object.path())?; + } else { + fs::remove_file(object.path())?; + } + } + } + if fs::read_dir(prefix.path())?.next().is_none() { + fs::remove_dir(prefix.path())?; + } + } + Ok(()) } struct CarrierCache { diff --git a/tests/carrier_git.rs b/tests/carrier_git.rs index 791c9fb..e67b12a 100644 --- a/tests/carrier_git.rs +++ b/tests/carrier_git.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::env; use std::fs; use std::path::Path; @@ -6,9 +7,14 @@ use std::sync::{Arc, Barrier, Mutex, MutexGuard}; use std::thread; use std::time::{Duration, Instant}; -use git_remote_e2ee::crypto::KeyFile; -use git_remote_e2ee::repository::EncryptedRepository; -use git_remote_e2ee::storage::{CasConflict, GitStorage, Storage}; +use git_remote_e2ee::crypto::{KeyFile, object_id, random_key}; +use git_remote_e2ee::manifest::{ + Manifest, ManifestAuthorization, open_manifest, peek_manifest_header, seal_manifest, + unwrap_generation_key, wrap_predecessor_key, +}; +use git_remote_e2ee::policy::{DeviceRoles, PolicyState}; +use git_remote_e2ee::repository::{EncryptedRepository, RecoveryClass, RecoveryOptions}; +use git_remote_e2ee::storage::{CasConflict, GitStorage, ObjectKind, Storage}; static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -97,6 +103,107 @@ fn init_carrier(root: &Path, carrier: &Path, key: &KeyFile) { .unwrap(); } +fn create_reader_signed_junk( + storage: &GitStorage, + reader: &KeyFile, + policy: &PolicyState, + parent_id: &str, +) -> String { + let encrypted = storage.get_object(ObjectKind::Manifest, parent_id).unwrap(); + let header = peek_manifest_header(&encrypted).unwrap(); + let parent_key = unwrap_generation_key(&header, reader).unwrap(); + let opened = open_manifest(&encrypted, &parent_key).unwrap(); + let parent = opened.manifest(); + let generation_key = random_key(); + let manifest = Manifest { + format_version: parent.format_version, + repository_root: parent.repository_root.clone(), + generation: parent.generation + 1, + previous: Some(parent_id.to_owned()), + policy_id: parent.policy_id.clone(), + policy_generation: parent.policy_generation, + authorization: ManifestAuthorization::Writer, + total_pack_count: parent.total_pack_count, + refs: parent.refs.clone(), + new_packs: Vec::new(), + predecessor_key_wrap: Some( + wrap_predecessor_key( + &generation_key, + &parent_key, + &parent.repository_root, + parent.generation + 1, + parent_id, + ) + .unwrap(), + ), + introduced_policy: None, + }; + let encrypted = seal_manifest( + reader, + policy, + &generation_key, + ManifestAuthorization::Writer, + manifest, + ) + .unwrap(); + let id = object_id(&encrypted); + storage + .put_object_if_absent(ObjectKind::Manifest, &id, &encrypted) + .unwrap(); + id +} + +fn create_writer_successor( + storage: &GitStorage, + writer: &KeyFile, + policy: &PolicyState, + parent_id: &str, + refs: BTreeMap, +) -> String { + let encrypted = storage.get_object(ObjectKind::Manifest, parent_id).unwrap(); + let header = peek_manifest_header(&encrypted).unwrap(); + let parent_key = unwrap_generation_key(&header, writer).unwrap(); + let opened = open_manifest(&encrypted, &parent_key).unwrap(); + let parent = opened.manifest(); + let generation_key = random_key(); + let manifest = Manifest { + format_version: parent.format_version, + repository_root: parent.repository_root.clone(), + generation: parent.generation + 1, + previous: Some(parent_id.to_owned()), + policy_id: parent.policy_id.clone(), + policy_generation: parent.policy_generation, + authorization: ManifestAuthorization::Writer, + total_pack_count: parent.total_pack_count, + refs, + new_packs: Vec::new(), + predecessor_key_wrap: Some( + wrap_predecessor_key( + &generation_key, + &parent_key, + &parent.repository_root, + parent.generation + 1, + parent_id, + ) + .unwrap(), + ), + introduced_policy: None, + }; + let encrypted = seal_manifest( + writer, + policy, + &generation_key, + ManifestAuthorization::Writer, + manifest, + ) + .unwrap(); + let id = object_id(&encrypted); + storage + .put_object_if_absent(ObjectKind::Manifest, &id, &encrypted) + .unwrap(); + id +} + fn add_helper_to_path(command: &mut Command) { let helper = Path::new(env!("CARGO_BIN_EXE_git-remote-e2ee")); let mut paths = vec![helper.parent().unwrap().to_path_buf()]; @@ -736,6 +843,723 @@ fn carrier_git_detects_tampered_ciphertext_chunk() { ); } +#[test] +fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertext() { + 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"); + let fresh = temporary.path().join("fresh"); + let cache_root = temporary.path().join("carrier-cache"); + init_source(&source, "legitimate descendant content"); + init_source(&returning, "returning local content"); + init_source(&fresh, "fresh local content"); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + let (reader_policy_manifest, _) = repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + let policy_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let policy_encrypted = policy_storage + .get_object(ObjectKind::Manifest, &reader_policy_manifest) + .unwrap(); + let policy_header = peek_manifest_header(&policy_encrypted).unwrap(); + let policy_key = unwrap_generation_key(&policy_header, &reader).unwrap(); + let policy_manifest = open_manifest(&policy_encrypted, &policy_key).unwrap(); + let floor_policy = PolicyState::parse( + policy_manifest + .manifest() + .introduced_policy + .as_deref() + .unwrap(), + ) + .unwrap(); + let pushed_object = git(&source, &["rev-parse", "HEAD"], false); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + let (floor_id, floor_manifest) = repository.current_manifest().unwrap(); + let pack_id = floor_manifest.new_packs[0].id.clone(); + repository.fetch_into(&returning, "private").unwrap(); + let floor_generation: u64 = serde_json::from_slice::( + &fs::read(returning.join(".git/git-remote-e2ee/private/state.json")).unwrap(), + ) + .unwrap()["generation"] + .as_u64() + .unwrap(); + assert_eq!(floor_manifest.generation, floor_generation); + repository + .revoke_device(&reader.device_id().unwrap(), &admin_pin) + .unwrap(); + let (legitimate_id, legitimate) = repository.current_manifest().unwrap(); + assert_eq!(legitimate.refs["refs/heads/main"], pushed_object); + + let attacker = temporary.path().join("carrier-edit"); + git( + temporary.path(), + &[ + "clone", + "-q", + "-b", + "git-remote-e2ee", + carrier.to_str().unwrap(), + attacker.to_str().unwrap(), + ], + false, + ); + git(&attacker, &["config", "user.name", "Carrier editor"], false); + git( + &attacker, + &["config", "user.email", "carrier-editor@example.invalid"], + false, + ); + fs::remove_dir_all( + attacker + .join("e2ee/objects") + .join(&pack_id[..2]) + .join(&pack_id), + ) + .unwrap(); + fs::write(attacker.join("e2ee/HEAD"), format!("{floor_id}\n")).unwrap(); + git(&attacker, &["add", "-A", "e2ee"], false); + git( + &attacker, + &["commit", "-q", "-m", "replay floor and delete pack"], + false, + ); + git( + &attacker, + &["push", "-q", "origin", "git-remote-e2ee"], + false, + ); + + let replay_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk_id = create_reader_signed_junk(&replay_storage, &reader, &floor_policy, &floor_id); + replay_storage + .compare_and_swap_head(Some(&floor_id), &junk_id) + .unwrap(); + + let recovery_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let recoverer = EncryptedRepository::new(recovery_storage, owner.clone()); + let report = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: false, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert_eq!(report.default_base.as_deref(), Some(legitimate_id.as_str())); + assert!(report.replays.contains(&floor_id)); + assert!( + report + .candidates + .iter() + .any(|candidate| candidate.manifest_id == legitimate_id) + ); + + let older_base = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: Some(&floor_id), + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert!( + older_base + .blocked_reason + .as_deref() + .unwrap() + .contains("--discard-newer") + ); + + let recovered = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + let recovered_id = recovered.published_manifest.unwrap(); + let (current_id, manifest) = recoverer.current_manifest().unwrap(); + assert_eq!(current_id, recovered_id); + assert_eq!(manifest.previous.as_deref(), Some(legitimate_id.as_str())); + assert_eq!(manifest.generation, floor_generation + 2); + assert_eq!(manifest.refs["refs/heads/main"], pushed_object); + assert!( + GitStorage::open(carrier.to_str().unwrap()) + .unwrap() + .open_object(ObjectKind::Pack, &pack_id) + .is_ok() + ); + let recovered_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let recovered_bytes = recovered_storage + .get_object(ObjectKind::Manifest, ¤t_id) + .unwrap(); + let recovered_header = peek_manifest_header(&recovered_bytes).unwrap(); + assert!(unwrap_generation_key(&recovered_header, &reader).is_err()); + + recoverer.fetch_into(&returning, "private").unwrap(); + recoverer.fetch_into(&fresh, "private").unwrap(); + assert_eq!( + git( + &fresh, + &["show", "refs/remotes/private/main:note.md"], + false + ), + "legitimate descendant content" + ); + let carrier_head = Command::new("git") + .arg(format!("--git-dir={}", carrier.display())) + .args(["show", "refs/heads/git-remote-e2ee:e2ee/HEAD"]) + .output() + .unwrap(); + assert!(carrier_head.status.success()); + assert_eq!( + String::from_utf8(carrier_head.stdout).unwrap().trim(), + current_id + ); + assert!(cache_root.exists()); +} + +#[test] +fn carrier_recovery_offers_hidden_legitimate_content_descendant() { + 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"); + let fresh = temporary.path().join("fresh"); + init_source(&source, "legitimate descendant content"); + init_source(&returning, "returning local content"); + init_source(&fresh, "fresh local content"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + let (floor_id, _) = repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + let floor_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let floor_bytes = floor_storage + .get_object(ObjectKind::Manifest, &floor_id) + .unwrap(); + let floor_header = peek_manifest_header(&floor_bytes).unwrap(); + let floor_key = unwrap_generation_key(&floor_header, &reader).unwrap(); + let floor_opened = open_manifest(&floor_bytes, &floor_key).unwrap(); + let floor_policy = PolicyState::parse( + floor_opened + .manifest() + .introduced_policy + .as_deref() + .unwrap(), + ) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + + let pushed_object = git(&source, &["rev-parse", "HEAD"], false); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + let (legitimate_id, legitimate) = repository.current_manifest().unwrap(); + assert_eq!(legitimate.refs["refs/heads/main"], pushed_object); + + let attacker = temporary.path().join("carrier-edit"); + git( + temporary.path(), + &[ + "clone", + "-q", + "-b", + "git-remote-e2ee", + carrier.to_str().unwrap(), + attacker.to_str().unwrap(), + ], + false, + ); + git(&attacker, &["config", "user.name", "Carrier editor"], false); + git( + &attacker, + &["config", "user.email", "carrier-editor@example.invalid"], + false, + ); + fs::write(attacker.join("e2ee/HEAD"), format!("{floor_id}\n")).unwrap(); + git(&attacker, &["add", "-A", "e2ee"], false); + git(&attacker, &["commit", "-q", "-m", "replay floor"], false); + git( + &attacker, + &["push", "-q", "origin", "git-remote-e2ee"], + false, + ); + + let replay_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk_id = create_reader_signed_junk(&replay_storage, &reader, &floor_policy, &floor_id); + replay_storage + .compare_and_swap_head(Some(&floor_id), &junk_id) + .unwrap(); + + let recoverer = + EncryptedRepository::new(GitStorage::open(carrier.to_str().unwrap()).unwrap(), owner); + let report = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: false, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert_eq!(report.default_base.as_deref(), Some(legitimate_id.as_str())); + assert!(report.replays.contains(&floor_id)); + assert!(report.candidates.iter().any(|candidate| { + candidate.manifest_id == legitimate_id && candidate.generation == legitimate.generation + })); + + let stale_choice = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: Some(&floor_id), + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert!( + stale_choice + .blocked_reason + .as_deref() + .unwrap() + .contains("--discard-newer") + ); + + let recovered = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + let recovered_id = recovered.published_manifest.unwrap(); + let (head, manifest) = recoverer.current_manifest().unwrap(); + assert_eq!(head, recovered_id); + assert_eq!(manifest.previous.as_deref(), Some(legitimate_id.as_str())); + assert_eq!(manifest.refs["refs/heads/main"], pushed_object); + recoverer.fetch_into(&returning, "private").unwrap(); + recoverer.fetch_into(&fresh, "private").unwrap(); + assert_eq!( + git( + &fresh, + &["show", "refs/remotes/private/main:note.md"], + false + ), + "legitimate descendant content" + ); +} + +#[test] +fn carrier_recovery_refuses_history_with_a_merge_hiding_a_legitimate_state() { + 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, "legitimate content"); + init_source(&returning, "returning local content"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + let (floor_id, _) = repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + let floor_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let floor_bytes = floor_storage + .get_object(ObjectKind::Manifest, &floor_id) + .unwrap(); + let floor_header = peek_manifest_header(&floor_bytes).unwrap(); + let floor_key = unwrap_generation_key(&floor_header, &reader).unwrap(); + let floor_opened = open_manifest(&floor_bytes, &floor_key).unwrap(); + let floor_policy = PolicyState::parse( + floor_opened + .manifest() + .introduced_policy + .as_deref() + .unwrap(), + ) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + + // Put a replay of the floor on the first-parent path and the legitimate + // tip on the second parent. The merge still fast-forwards the carrier. + let attacker = temporary.path().join("carrier-edit"); + git( + temporary.path(), + &[ + "clone", + "-q", + "-b", + "git-remote-e2ee", + carrier.to_str().unwrap(), + attacker.to_str().unwrap(), + ], + false, + ); + git(&attacker, &["config", "user.name", "Carrier editor"], false); + git( + &attacker, + &["config", "user.email", "carrier-editor@example.invalid"], + false, + ); + git( + &attacker, + &["checkout", "-q", "-b", "side", "HEAD~1"], + false, + ); + git( + &attacker, + &["commit", "-q", "--allow-empty", "-m", "replay floor"], + false, + ); + git( + &attacker, + &[ + "merge", + "-q", + "--no-ff", + "-s", + "ours", + "-m", + "hide", + "git-remote-e2ee", + ], + false, + ); + git( + &attacker, + &["push", "-q", "origin", "side:git-remote-e2ee"], + false, + ); + + let junk_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk_id = create_reader_signed_junk(&junk_storage, &reader, &floor_policy, &floor_id); + junk_storage + .compare_and_swap_head(Some(&floor_id), &junk_id) + .unwrap(); + + let recoverer = + EncryptedRepository::new(GitStorage::open(carrier.to_str().unwrap()).unwrap(), owner); + let error = recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("merge commit"), "{error:#}"); + let after = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + assert_eq!( + after.read_head().unwrap().as_deref(), + Some(junk_id.as_str()) + ); +} + +#[test] +fn carrier_recovery_refuses_conflicting_authenticated_descendants() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + let returning = temporary.path().join("returning"); + init_source(&returning, "local state"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + let (floor_id, _) = repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + + let policy_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let policy_encrypted = policy_storage + .get_object(ObjectKind::Manifest, &floor_id) + .unwrap(); + let policy_header = peek_manifest_header(&policy_encrypted).unwrap(); + let policy_key = unwrap_generation_key(&policy_header, &owner).unwrap(); + let policy_manifest = open_manifest(&policy_encrypted, &policy_key).unwrap(); + let policy = PolicyState::parse( + policy_manifest + .manifest() + .introduced_policy + .as_deref() + .unwrap(), + ) + .unwrap(); + + let first_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let first_fork = create_writer_successor( + &first_storage, + &owner, + &policy, + &floor_id, + BTreeMap::from([("refs/heads/main".to_owned(), "1".repeat(40))]), + ); + first_storage + .compare_and_swap_head(Some(&floor_id), &first_fork) + .unwrap(); + let second_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let second_fork = create_writer_successor( + &second_storage, + &owner, + &policy, + &floor_id, + BTreeMap::from([("refs/heads/main".to_owned(), "2".repeat(40))]), + ); + second_storage + .compare_and_swap_head(Some(&first_fork), &second_fork) + .unwrap(); + let junk_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk = create_reader_signed_junk(&junk_storage, &reader, &policy, &floor_id); + junk_storage + .compare_and_swap_head(Some(&second_fork), &junk) + .unwrap(); + + let recovery_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let report = EncryptedRepository::new(recovery_storage, owner) + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert!(report.conflict); + assert!( + report + .blocked_reason + .as_deref() + .unwrap() + .contains("refusing to choose between forks") + ); + assert!( + report + .candidates + .iter() + .any(|candidate| candidate.manifest_id == first_fork) + ); + assert!( + report + .candidates + .iter() + .any(|candidate| candidate.manifest_id == second_fork) + ); + let current = GitStorage::open(carrier.to_str().unwrap()) + .unwrap() + .read_head() + .unwrap(); + assert_eq!(current.as_deref(), Some(junk.as_str())); +} + +#[test] +fn carrier_recovery_loses_cas_without_retrying_when_another_writer_publishes() { + let temporary = tempfile::tempdir().unwrap(); + let _cache = isolated_cache(&temporary); + let carrier = temporary.path().join("carrier.git"); + let returning = temporary.path().join("returning"); + init_source(&returning, "local state"); + git( + temporary.path(), + &["init", "--bare", "-q", carrier.to_str().unwrap()], + false, + ); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let repository = EncryptedRepository::new( + GitStorage::open(carrier.to_str().unwrap()).unwrap(), + owner.clone(), + ); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + let (floor_id, _) = repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + repository.fetch_into(&returning, "private").unwrap(); + let policy_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let policy_encrypted = policy_storage + .get_object(ObjectKind::Manifest, &floor_id) + .unwrap(); + let policy_header = peek_manifest_header(&policy_encrypted).unwrap(); + let policy_key = unwrap_generation_key(&policy_header, &reader).unwrap(); + let policy_manifest = open_manifest(&policy_encrypted, &policy_key).unwrap(); + let policy = PolicyState::parse( + policy_manifest + .manifest() + .introduced_policy + .as_deref() + .unwrap(), + ) + .unwrap(); + + let bad_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let junk = create_reader_signed_junk(&bad_storage, &reader, &policy, &floor_id); + bad_storage + .compare_and_swap_head(Some(&floor_id), &junk) + .unwrap(); + let stale_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let stale_recoverer = EncryptedRepository::new(stale_storage, owner.clone()); + let offered = stale_recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: false, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(offered.classification, RecoveryClass::Invalid); + assert_eq!(offered.default_base.as_deref(), Some(floor_id.as_str())); + + let competing_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); + let winner = create_writer_successor( + &competing_storage, + &owner, + &policy, + &floor_id, + BTreeMap::new(), + ); + competing_storage + .compare_and_swap_head(Some(&junk), &winner) + .unwrap(); + + let error = stale_recoverer + .recover( + &returning, + "private", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("concurrently")); + let latest = + EncryptedRepository::new(GitStorage::open(carrier.to_str().unwrap()).unwrap(), owner); + assert_eq!(latest.current_manifest().unwrap().0, winner); +} + #[test] fn carrier_branch_coexists_with_unrelated_repository_history() { let temporary = tempfile::tempdir().unwrap(); diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 2d729b2..800043e 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -7,14 +7,17 @@ use std::sync::{Arc, Barrier}; use std::thread; use anyhow::Result; +use base64::Engine; use git_remote_e2ee::crypto::{KeyFile, object_id, random_key}; use git_remote_e2ee::manifest::{ Manifest, ManifestAuthorization, open_manifest, peek_manifest_header, seal_manifest, unwrap_generation_key, verify_manifest, wrap_predecessor_key, }; -use git_remote_e2ee::policy::PolicyState; -use git_remote_e2ee::repository::EncryptedRepository; -use git_remote_e2ee::storage::{FilesystemStorage, ObjectKind, ObjectStage, Storage}; +use git_remote_e2ee::policy::{DeviceRoles, PolicyState}; +use git_remote_e2ee::repository::{EncryptedRepository, RecoveryClass, RecoveryOptions}; +use git_remote_e2ee::storage::{ + FilesystemStorage, ObjectKind, ObjectStage, RecoveryCommit, RecoveryHistory, Storage, +}; #[derive(Clone)] struct BarrierStorage { @@ -24,6 +27,45 @@ struct BarrierStorage { barrier: Arc, } +#[derive(Clone)] +struct OverBudgetHistoryStorage { + inner: FilesystemStorage, +} + +impl Storage for OverBudgetHistoryStorage { + 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: (0..=max_commits) + .map(|index| RecoveryCommit { + commit_id: format!("commit-{index}"), + head_id: self.inner.read_head().unwrap(), + }) + .collect(), + manifests: Default::default(), + })) + } +} + impl Storage for BarrierStorage { fn begin_object(&self, kind: ObjectKind) -> Result> { self.inner.begin_object(kind) @@ -79,6 +121,14 @@ fn commit(repo: &Path, contents: &str, message: &str) -> String { git(repo, &["rev-parse", "HEAD"]) } +fn client_state(repo: &Path, remote_name: &str) -> serde_json::Value { + let path = repo + .join(".git/git-remote-e2ee") + .join(remote_name) + .join("state.json"); + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + fn copy_tree(source: &Path, destination: &Path) { fs::create_dir_all(destination).unwrap(); for entry in fs::read_dir(source).unwrap() { @@ -137,6 +187,102 @@ fn pushes_incrementally_and_fetches_into_another_repository() { ); } +#[test] +fn missing_pack_observation_does_not_advance_the_floor() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let destination = temporary.path().join("destination"); + let remote_path = temporary.path().join("remote"); + initialize_git(&source); + initialize_git(&destination); + + let key = KeyFile::generate(); + let repository = EncryptedRepository::new(FilesystemStorage::new(&remote_path), key); + repository.initialize().unwrap(); + commit(&source, "first\n", "first"); + repository + .push_update_for_remote( + &source, + "encrypted", + "refs/heads/main", + "refs/heads/main", + false, + ) + .unwrap(); + repository.fetch_into(&destination, "encrypted").unwrap(); + assert_eq!(client_state(&destination, "encrypted")["generation"], 1); + assert_eq!( + client_state(&destination, "encrypted")["imported_generation"], + 1 + ); + + commit(&source, "second\n", "second"); + repository + .push_update_for_remote( + &source, + "encrypted", + "refs/heads/main", + "refs/heads/main", + false, + ) + .unwrap(); + let (_, manifest) = repository.current_manifest().unwrap(); + let pack_id = manifest.new_packs[0].id.clone(); + let pack_path = remote_path + .join("objects") + .join(&pack_id[..2]) + .join(&pack_id); + let saved_pack = fs::read(&pack_path).unwrap(); + fs::remove_file(&pack_path).unwrap(); + + let listed = repository + .observe_manifest(&destination, "encrypted") + .unwrap(); + assert_eq!(listed.generation, 2); + assert_eq!(client_state(&destination, "encrypted")["generation"], 1); + let error = repository + .fetch_into(&destination, "encrypted") + .unwrap_err() + .to_string(); + assert!(error.contains(&pack_id), "unexpected error: {error}"); + assert_eq!(client_state(&destination, "encrypted")["generation"], 1); + + write_stored_object(&remote_path, "objects", &pack_id, &saved_pack); + let fetched = repository.fetch_into(&destination, "encrypted").unwrap(); + assert_eq!(fetched.generation, 2); + let state = client_state(&destination, "encrypted"); + assert_eq!(state["generation"], 2); + assert_eq!(state["imported_generation"], 2); +} + +#[test] +fn successful_own_publication_advances_the_client_floor() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let remote_path = temporary.path().join("remote"); + initialize_git(&source); + + let key = KeyFile::generate(); + let repository = EncryptedRepository::new(FilesystemStorage::new(&remote_path), key); + repository.initialize().unwrap(); + commit(&source, "published\n", "published"); + let head = repository + .push_update_for_remote( + &source, + "encrypted", + "refs/heads/main", + "refs/heads/main", + false, + ) + .unwrap(); + + let state_path = source.join(".git/git-remote-e2ee/encrypted/state.json"); + let state: serde_json::Value = serde_json::from_slice(&fs::read(state_path).unwrap()).unwrap(); + assert_eq!(state["head_id"], head); + assert_eq!(state["generation"], 1); + assert_eq!(state["imported_generation"], serde_json::Value::Null); +} + #[test] fn returning_client_fetches_multiple_offline_generations_from_deltas() { let temporary = tempfile::tempdir().unwrap(); @@ -246,6 +392,8 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() { error.contains("does not resolve") || error.contains("missing Git objects"), "unexpected connectivity error: {error}" ); + assert_eq!(client_state(&destination, "e2ee")["generation"], 1); + assert_eq!(client_state(&destination, "e2ee")["imported_generation"], 1); } #[test] @@ -484,6 +632,439 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() { assert!(error.to_string().contains("history forked")); } +#[test] +fn recover_replaces_reader_signed_junk_from_the_directory_floor() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let returning = temporary.path().join("returning"); + let fresh = temporary.path().join("fresh"); + let storage_path = temporary.path().join("storage"); + initialize_git(&source); + initialize_git(&returning); + initialize_git(&fresh); + + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let storage = FilesystemStorage::new(&storage_path); + let repository = EncryptedRepository::new(storage.clone(), owner.clone()); + repository.initialize().unwrap(); + repository.pin_admin_state(&admin_pin).unwrap(); + commit(&source, "recovery keeps this content\n", "base"); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + repository.fetch_into(&returning, "encrypted").unwrap(); + let (floor_id, floor_manifest) = repository.current_manifest().unwrap(); + let floor_generation = floor_manifest.generation; + let floor_state = client_state(&returning, "encrypted"); + assert_eq!(floor_state["head_id"], floor_id); + + let encrypted = storage.get_object(ObjectKind::Manifest, &floor_id).unwrap(); + let header = peek_manifest_header(&encrypted).unwrap(); + let floor_key = unwrap_generation_key(&header, &reader).unwrap(); + let opened = open_manifest(&encrypted, &floor_key).unwrap(); + let policy = + PolicyState::parse(opened.manifest().introduced_policy.as_deref().unwrap()).unwrap(); + let recovery_key = random_key(); + let junk = Manifest { + format_version: floor_manifest.format_version, + repository_root: floor_manifest.repository_root.clone(), + generation: floor_generation + 1, + previous: Some(floor_id.clone()), + policy_id: floor_manifest.policy_id.clone(), + policy_generation: floor_manifest.policy_generation, + authorization: ManifestAuthorization::Writer, + total_pack_count: floor_manifest.total_pack_count, + refs: floor_manifest.refs.clone(), + new_packs: Vec::new(), + predecessor_key_wrap: Some( + wrap_predecessor_key( + &recovery_key, + &floor_key, + &floor_manifest.repository_root, + floor_generation + 1, + &floor_id, + ) + .unwrap(), + ), + introduced_policy: None, + }; + let junk_bytes = seal_manifest( + &reader, + &policy, + &recovery_key, + ManifestAuthorization::Writer, + junk, + ) + .unwrap(); + let junk_id = object_id(&junk_bytes); + storage + .put_object_if_absent(ObjectKind::Manifest, &junk_id, &junk_bytes) + .unwrap(); + storage + .compare_and_swap_head(Some(&floor_id), &junk_id) + .unwrap(); + + let recoverer = EncryptedRepository::new(storage.clone(), owner.clone()); + let blocked = recoverer + .recover( + &returning, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(blocked.classification, RecoveryClass::Invalid); + assert_eq!(blocked.default_base.as_deref(), Some(floor_id.as_str())); + assert!( + blocked + .warning + .as_deref() + .unwrap() + .starts_with("Updates after this floor") + ); + assert!( + blocked + .blocked_reason + .as_deref() + .unwrap() + .contains("--accept-stale-floor") + ); + assert_eq!(client_state(&returning, "encrypted")["head_id"], floor_id); + + let recovered = recoverer + .recover( + &returning, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) + .unwrap(); + let recovered_id = recovered.published_manifest.unwrap(); + let (current_id, current) = recoverer.current_manifest().unwrap(); + assert_eq!(current_id, recovered_id); + assert_eq!(current.previous.as_deref(), Some(floor_id.as_str())); + assert_eq!(current.generation, floor_generation + 1); + assert_ne!(current_id, junk_id); + assert_eq!(client_state(&returning, "encrypted")["head_id"], current_id); + let rejected = + fs::read(returning.join(".git/git-remote-e2ee/encrypted/rejected-heads.json")).unwrap(); + assert!(String::from_utf8_lossy(&rejected).contains(&junk_id)); + + recoverer.fetch_into(&returning, "encrypted").unwrap(); + recoverer.fetch_into(&fresh, "encrypted").unwrap(); + assert_eq!( + git(&fresh, &["show", "refs/remotes/encrypted/main:note.md"]), + "recovery keeps this content" + ); + assert_eq!(client_state(&returning, "encrypted")["head_id"], current_id); + assert_eq!(client_state(&fresh, "encrypted")["head_id"], current_id); +} + +#[test] +fn recover_refuses_a_revoked_devices_unverifiable_head() { + let temporary = tempfile::tempdir().unwrap(); + let reader_repo = temporary.path().join("reader"); + let storage_path = temporary.path().join("storage"); + initialize_git(&reader_repo); + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + let storage = FilesystemStorage::new(&storage_path); + let owner_repository = EncryptedRepository::new(storage.clone(), owner); + owner_repository.initialize().unwrap(); + owner_repository.pin_admin_state(&admin_pin).unwrap(); + owner_repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + owner_repository + .fetch_into(&reader_repo, "encrypted") + .unwrap(); + let old_floor = client_state(&reader_repo, "encrypted")["head_id"] + .as_str() + .unwrap() + .to_owned(); + owner_repository + .revoke_device(&reader.device_id().unwrap(), &admin_pin) + .unwrap(); + let revoked_head = storage.read_head().unwrap().unwrap(); + + let reader_repository = EncryptedRepository::new(storage.clone(), reader); + let report = reader_repository + .recover( + &reader_repo, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Unverifiable); + assert!(report.blocked_reason.unwrap().contains("forbidden")); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(revoked_head.as_str()) + ); + assert_eq!( + client_state(&reader_repo, "encrypted")["head_id"], + old_floor + ); + assert!( + !reader_repo + .join(".git/git-remote-e2ee/encrypted/rejected-heads.json") + .exists() + ); +} + +#[test] +fn recover_refuses_unknown_formats_and_missing_floor_objects() { + let temporary = tempfile::tempdir().unwrap(); + + let unknown_repo = temporary.path().join("unknown-repo"); + let unknown_storage_path = temporary.path().join("unknown-storage"); + initialize_git(&unknown_repo); + let key = KeyFile::generate(); + let unknown_storage = FilesystemStorage::new(&unknown_storage_path); + let unknown_repository = EncryptedRepository::new(unknown_storage.clone(), key.clone()); + unknown_repository.initialize().unwrap(); + unknown_repository + .fetch_into(&unknown_repo, "encrypted") + .unwrap(); + let floor_id = unknown_storage.read_head().unwrap().unwrap(); + let header = serde_json::json!({ + "format_version": 99, + "repository_root": key.repository_root, + "generation": 1, + "previous": floor_id, + "key_commitment": "0".repeat(64), + "generation_key_envelopes": [], + "body_digest": "0".repeat(64), + "sealed_header_digest": "0".repeat(64) + }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let unsupported_bytes = serde_json::to_vec(&serde_json::json!({ + "header": base64::engine::general_purpose::STANDARD.encode(header_bytes), + "sealed_header_ciphertext": "", + "body_ciphertext": "" + })) + .unwrap(); + let unsupported_id = object_id(&unsupported_bytes); + unknown_storage + .put_object_if_absent(ObjectKind::Manifest, &unsupported_id, &unsupported_bytes) + .unwrap(); + unknown_storage + .compare_and_swap_head(Some(&floor_id), &unsupported_id) + .unwrap(); + let unsupported = EncryptedRepository::new(unknown_storage.clone(), key) + .recover( + &unknown_repo, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) + .unwrap(); + assert_eq!(unsupported.classification, RecoveryClass::Unsupported); + assert_eq!( + unknown_storage.read_head().unwrap().as_deref(), + Some(unsupported_id.as_str()) + ); + + let missing_repo = temporary.path().join("missing-repo"); + let missing_storage_path = temporary.path().join("missing-storage"); + initialize_git(&missing_repo); + let missing_key = KeyFile::generate(); + let missing_storage = FilesystemStorage::new(&missing_storage_path); + let missing_repository = EncryptedRepository::new(missing_storage.clone(), missing_key.clone()); + missing_repository.initialize().unwrap(); + let source = temporary.path().join("missing-source"); + initialize_git(&source); + commit(&source, "required floor object\n", "required"); + missing_repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + missing_repository + .fetch_into(&missing_repo, "encrypted") + .unwrap(); + let floor_id = missing_storage.read_head().unwrap().unwrap(); + let floor_manifest = missing_repository.current_manifest().unwrap().1; + let missing_pack = &floor_manifest.new_packs[0].id; + fs::remove_file( + missing_storage_path + .join("objects") + .join(&missing_pack[..2]) + .join(missing_pack), + ) + .unwrap(); + let unavailable = missing_repository + .recover( + &missing_repo, + "encrypted", + RecoveryOptions { + publish: false, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap(); + assert_eq!(unavailable.classification, RecoveryClass::Unavailable); + let invalid_bytes = b"invalid successor"; + let invalid_id = object_id(invalid_bytes); + missing_storage + .put_object_if_absent(ObjectKind::Manifest, &invalid_id, invalid_bytes) + .unwrap(); + missing_storage + .compare_and_swap_head(Some(&floor_id), &invalid_id) + .unwrap(); + let refused = EncryptedRepository::new(missing_storage.clone(), missing_key) + .recover( + &missing_repo, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) + .unwrap(); + assert_eq!(refused.classification, RecoveryClass::Invalid); + assert!( + refused + .blocked_reason + .as_deref() + .unwrap() + .contains("ciphertext is unavailable") + ); + assert_eq!( + missing_storage.read_head().unwrap().as_deref(), + Some(invalid_id.as_str()) + ); + assert_eq!( + client_state(&missing_repo, "encrypted")["head_id"], + floor_id + ); +} + +#[test] +fn recover_never_overrides_authenticated_rollback() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let returning = temporary.path().join("returning"); + let storage_path = temporary.path().join("storage"); + initialize_git(&source); + initialize_git(&returning); + let key = KeyFile::generate(); + let storage = FilesystemStorage::new(&storage_path); + let repository = EncryptedRepository::new(storage.clone(), key); + repository.initialize().unwrap(); + commit(&source, "one\n", "one"); + let first = repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + commit(&source, "two\n", "two"); + repository + .push_ref(&source, "refs/heads/main", false) + .unwrap(); + repository.fetch_into(&returning, "encrypted").unwrap(); + storage + .compare_and_swap_head(Some(&repository.current_manifest().unwrap().0), &first) + .unwrap(); + + let report = repository + .recover( + &returning, + "encrypted", + RecoveryOptions { + publish: true, + base: None, + discard_newer: true, + accept_stale_floor: true, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Discontinuous); + assert!(report.blocked_reason.unwrap().contains("forbidden")); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(first.as_str()) + ); +} + +#[test] +fn recovery_discovery_budget_exhaustion_fails_closed() { + let temporary = tempfile::tempdir().unwrap(); + let repo = temporary.path().join("repo"); + let storage_path = temporary.path().join("storage"); + initialize_git(&repo); + let key = KeyFile::generate(); + let inner = FilesystemStorage::new(&storage_path); + let repository = EncryptedRepository::new(inner.clone(), key.clone()); + repository.initialize().unwrap(); + repository.fetch_into(&repo, "encrypted").unwrap(); + let floor = inner.read_head().unwrap().unwrap(); + let invalid_bytes = b"malformed successor"; + let invalid_id = object_id(invalid_bytes); + inner + .put_object_if_absent(ObjectKind::Manifest, &invalid_id, invalid_bytes) + .unwrap(); + inner + .compare_and_swap_head(Some(&floor), &invalid_id) + .unwrap(); + + let recovery = EncryptedRepository::new( + OverBudgetHistoryStorage { + inner: inner.clone(), + }, + key, + ); + let error = recovery + .recover( + &repo, + "encrypted", + RecoveryOptions { + publish: false, + base: None, + discard_newer: false, + accept_stale_floor: false, + }, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("outer-commit budget exhausted")); + assert_eq!( + inner.read_head().unwrap().as_deref(), + Some(invalid_id.as_str()) + ); + assert_eq!(client_state(&repo, "encrypted")["head_id"], floor); +} + #[test] fn writer_can_add_branch_without_having_other_remote_branch_objects() { let temporary = tempfile::tempdir().unwrap(); diff --git a/tests/multi_device.rs b/tests/multi_device.rs index 93bc578..c62fb1d 100644 --- a/tests/multi_device.rs +++ b/tests/multi_device.rs @@ -225,7 +225,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { } #[test] -fn membership_observation_pins_head_without_skipping_later_pack_import() { +fn connected_membership_observation_pins_and_rejects_replay() { let temp = tempfile::tempdir().unwrap(); let remote = temp.path().join("remote"); let source = temp.path().join("source"); @@ -246,6 +246,9 @@ fn membership_observation_pins_head_without_skipping_later_pack_import() { repository.fetch_into(&client, "e2ee").unwrap(); let before_membership = fs::read_to_string(remote.join("HEAD")).unwrap(); + // The membership change keeps every ref, so the client already holds a + // fully connected copy of the advertised state. Native Git would not send + // a fetch command here, so observation itself must raise the floor. repository .add_device( key_b.public_device().unwrap(), @@ -253,15 +256,19 @@ fn membership_observation_pins_head_without_skipping_later_pack_import() { &admin_pin, ) .unwrap(); - repository.observe_manifest(&client, "e2ee").unwrap(); + let observed = repository.observe_manifest(&client, "e2ee").unwrap(); + assert_eq!(observed.generation, 2); + let state_path = client.join(".git/git-remote-e2ee/e2ee/state.json"); + let state: serde_json::Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(state["generation"], 2); + assert_eq!(state["policy_generation"], 1); let after_membership = fs::read_to_string(remote.join("HEAD")).unwrap(); fs::write(remote.join("HEAD"), &before_membership).unwrap(); - let error = repository - .observe_manifest(&client, "e2ee") - .unwrap_err() - .to_string(); - assert!(error.contains("rolled back"), "unexpected error: {error}"); + let error = repository.observe_manifest(&client, "e2ee").unwrap_err(); + assert!(format!("{error:#}").contains("rolled back"), "{error:#}"); + let state: serde_json::Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(state["generation"], 2); fs::write(remote.join("HEAD"), after_membership).unwrap(); commit(&source, "two\n", "two"); diff --git a/tests/read_only_helper.rs b/tests/read_only_helper.rs index 264b557..77d4af0 100644 --- a/tests/read_only_helper.rs +++ b/tests/read_only_helper.rs @@ -6,8 +6,8 @@ use std::sync::{Mutex, MutexGuard}; use git_remote_e2ee::crypto::KeyFile; use git_remote_e2ee::policy::DeviceRoles; -use git_remote_e2ee::repository::EncryptedRepository; -use git_remote_e2ee::storage::{FilesystemStorage, GitStorage, Storage}; +use git_remote_e2ee::repository::{EncryptedRepository, RecoveryClass, RecoveryOptions}; +use git_remote_e2ee::storage::{FilesystemStorage, GitStorage, ObjectKind, Storage}; static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -251,3 +251,61 @@ fn read_only_devices_use_both_storage_backends_without_publishing() { &carrier_url, ); } + +#[test] +fn read_only_device_cannot_publish_recovery() { + let temporary = tempfile::tempdir().unwrap(); + let storage_path = temporary.path().join("storage"); + let reader_repo = temporary.path().join("reader"); + initialize_git(&reader_repo); + let owner = KeyFile::generate(); + let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap(); + let storage = FilesystemStorage::new(&storage_path); + let owner_repository = EncryptedRepository::new(storage.clone(), owner); + owner_repository.initialize().unwrap(); + let admin_pin = temporary.path().join("owner.admin-state.json"); + owner_repository.pin_admin_state(&admin_pin).unwrap(); + owner_repository + .add_device( + reader.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + owner_repository.fetch_into(&reader_repo, "origin").unwrap(); + let (floor_id, _) = owner_repository.current_manifest().unwrap(); + let invalid_bytes = b"malformed but content-addressed manifest"; + let invalid_id = git_remote_e2ee::crypto::object_id(invalid_bytes); + storage + .put_object_if_absent(ObjectKind::Manifest, &invalid_id, invalid_bytes) + .unwrap(); + storage + .compare_and_swap_head(Some(&floor_id), &invalid_id) + .unwrap(); + + let read_only = EncryptedRepository::new(storage.clone(), reader); + let report = read_only + .recover( + &reader_repo, + "origin", + RecoveryOptions { + publish: true, + base: None, + discard_newer: false, + accept_stale_floor: true, + }, + ) + .unwrap(); + assert_eq!(report.classification, RecoveryClass::Invalid); + assert!( + report + .blocked_reason + .as_deref() + .unwrap() + .contains("not an active writer") + ); + assert_eq!( + storage.read_head().unwrap().as_deref(), + Some(invalid_id.as_str()) + ); +} diff --git a/tests/remote_helper.rs b/tests/remote_helper.rs index 73e013c..b40bbfe 100644 --- a/tests/remote_helper.rs +++ b/tests/remote_helper.rs @@ -1,9 +1,11 @@ use std::env; use std::fs; +use std::io::Write; use std::path::Path; -use std::process::{Command, Output}; +use std::process::{Command, Output, Stdio}; use git_remote_e2ee::crypto::KeyFile; +use git_remote_e2ee::policy::DeviceRoles; use git_remote_e2ee::repository::EncryptedRepository; use git_remote_e2ee::storage::FilesystemStorage; @@ -130,6 +132,97 @@ fn native_git_push_and_fetch_use_the_remote_helper() { ); } +#[test] +fn concurrent_helper_processes_preserve_the_client_state_floor() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let destination = temporary.path().join("destination"); + let remote_path = temporary.path().join("remote"); + let stale_remote = temporary.path().join("stale-remote"); + let key_path = temporary.path().join("repository.key.json"); + initialize_git(&source); + initialize_git(&destination); + + let key = KeyFile::generate(); + key.write_new(&key_path).unwrap(); + let encrypted = EncryptedRepository::new(FilesystemStorage::new(&remote_path), key); + encrypted.initialize().unwrap(); + configure_remote(&source, &remote_path, &key_path); + configure_remote(&destination, &remote_path, &key_path); + encrypted.fetch_into(&destination, "private").unwrap(); + + fs::write(source.join("note.md"), "first\n").unwrap(); + git(&source, &["add", "note.md"], false); + git(&source, &["commit", "-q", "-m", "first"], false); + git(&source, &["push", "private", "main"], true); + let stale_object = git(&source, &["rev-parse", "HEAD"], false); + copy_tree(&remote_path, &stale_remote); + + fs::write(source.join("note.md"), "second\n").unwrap(); + git(&source, &["add", "note.md"], false); + git(&source, &["commit", "-q", "-m", "second"], false); + let advertised_object = git(&source, &["rev-parse", "HEAD"], false); + git(&source, &["push", "private", "main"], true); + let (expected_head, expected_manifest) = encrypted.current_manifest().unwrap(); + + let current_url = format!("e2ee::{}", remote_path.display()); + let stale_url = format!("e2ee::{}", stale_remote.display()); + let helper = env!("CARGO_BIN_EXE_git-remote-e2ee"); + let mut children = Vec::new(); + for remote_url in [&stale_url, &stale_url, ¤t_url, ¤t_url] { + children.push( + Command::new(helper) + .arg("private") + .arg(remote_url) + .current_dir(&destination) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + } + for (index, child) in children.iter_mut().enumerate() { + let object = if index < 2 { + &stale_object + } else { + &advertised_object + }; + let protocol = format!("capabilities\nlist\n\nfetch {object} refs/heads/main\n\n"); + child + .stdin + .take() + .unwrap() + .write_all(protocol.as_bytes()) + .unwrap(); + } + let mut current_successes = 0; + for (index, child) in children.into_iter().enumerate() { + let output = child.wait_with_output().unwrap(); + if index >= 2 { + assert!( + output.status.success(), + "current helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + current_successes += 1; + } else if !output.status.success() { + assert!( + String::from_utf8_lossy(&output.stderr).contains("rolled back"), + "stale helper failed unexpectedly: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + assert_eq!(current_successes, 2); + + let state_path = destination.join(".git/git-remote-e2ee/private/state.json"); + let state: serde_json::Value = serde_json::from_slice(&fs::read(state_path).unwrap()).unwrap(); + assert_eq!(state["generation"], expected_manifest.generation); + assert_eq!(state["imported_generation"], expected_manifest.generation); + assert_eq!(state["head_id"], expected_head); +} + #[test] fn native_git_dry_run_does_not_mutate_remote() { let temporary = tempfile::tempdir().unwrap(); @@ -304,3 +397,101 @@ fn native_git_fetch_rejects_same_generation_manifest_fork() { String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn native_git_fetch_pins_membership_changes_that_transfer_no_objects() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let destination = temporary.path().join("destination"); + let remote_path = temporary.path().join("remote"); + let key_path = temporary.path().join("repository.key.json"); + let admin_pin = temporary + .path() + .join("repository.key.json.admin-state.json"); + initialize_git(&source); + initialize_git(&destination); + + let key = KeyFile::generate(); + key.write_new(&key_path).unwrap(); + let extra = KeyFile::generate_for_repository(key.repository_root.clone()).unwrap(); + let encrypted = EncryptedRepository::new(FilesystemStorage::new(&remote_path), key); + encrypted.initialize().unwrap(); + encrypted.pin_admin_state(&admin_pin).unwrap(); + configure_remote(&source, &remote_path, &key_path); + configure_remote(&destination, &remote_path, &key_path); + + fs::write(source.join("note.md"), "first\n").unwrap(); + git(&source, &["add", "note.md"], false); + git(&source, &["commit", "-q", "-m", "first"], false); + git(&source, &["push", "private", "main"], true); + git(&destination, &["fetch", "private"], true); + let before_membership_change = encrypted.current_manifest().unwrap().0; + + // A membership change publishes a new generation without changing refs, so + // Git finds nothing to transfer and never sends the helper a fetch command. + encrypted + .add_device( + extra.public_device().unwrap(), + DeviceRoles::reader(), + &admin_pin, + ) + .unwrap(); + git(&destination, &["fetch", "private"], true); + + fs::write( + remote_path.join("HEAD"), + format!("{before_membership_change}\n"), + ) + .unwrap(); + let output = git_output(&destination, &["fetch", "private"], true); + assert!( + !output.status.success(), + "replaying the pre-change manifest must be rejected" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("rolled back"), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn native_git_fetch_pins_branch_deletions_that_transfer_no_objects() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let destination = temporary.path().join("destination"); + let remote_path = temporary.path().join("remote"); + let key_path = temporary.path().join("repository.key.json"); + initialize_git(&source); + initialize_git(&destination); + + let key = KeyFile::generate(); + key.write_new(&key_path).unwrap(); + let encrypted = EncryptedRepository::new(FilesystemStorage::new(&remote_path), key); + encrypted.initialize().unwrap(); + configure_remote(&source, &remote_path, &key_path); + configure_remote(&destination, &remote_path, &key_path); + + fs::write(source.join("note.md"), "first\n").unwrap(); + git(&source, &["add", "note.md"], false); + git(&source, &["commit", "-q", "-m", "first"], false); + git(&source, &["branch", "feature"], false); + git(&source, &["push", "private", "main", "feature"], true); + git(&destination, &["fetch", "private"], true); + let before_deletion = encrypted.current_manifest().unwrap().0; + + git(&source, &["push", "private", "--delete", "feature"], true); + git(&destination, &["fetch", "--prune", "private"], true); + + fs::write(remote_path.join("HEAD"), format!("{before_deletion}\n")).unwrap(); + let output = git_output(&destination, &["fetch", "private"], true); + assert!( + !output.status.success(), + "replaying the pre-deletion manifest must be rejected" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("rolled back"), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); +}