From 38c8acb0305e491f84d3a456e6efd473c3c7d596 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 14:43:33 -0400 Subject: [PATCH 01/53] feat: enforce conflict recovery policy --- crates/bitbygit-core/src/lib.rs | 42 +++ crates/bitbygit-core/src/prompt_parser.rs | 39 +- crates/bitbygit-git/src/lib.rs | 59 +++ crates/bitbygit-tui/src/lib.rs | 427 +++++++++++++++++++++- 4 files changed, 562 insertions(+), 5 deletions(-) diff --git a/crates/bitbygit-core/src/lib.rs b/crates/bitbygit-core/src/lib.rs index a698ca7..b4b21ea 100644 --- a/crates/bitbygit-core/src/lib.rs +++ b/crates/bitbygit-core/src/lib.rs @@ -39,6 +39,7 @@ pub enum OperationRequest { Rebase { base: String, }, + Recover(RecoveryRequest), OpenPullRequest { base: Option, }, @@ -47,6 +48,32 @@ pub enum OperationRequest { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryRequest { + MergeContinue, + MergeAbort, + RebaseContinue, + RebaseAbort, + RebaseSkip, +} + +impl RecoveryRequest { + pub const fn operation_label(self) -> &'static str { + match self { + Self::MergeContinue | Self::MergeAbort => "merge", + Self::RebaseContinue | Self::RebaseAbort | Self::RebaseSkip => "rebase", + } + } + + pub const fn action_label(self) -> &'static str { + match self { + Self::MergeContinue | Self::RebaseContinue => "continue", + Self::MergeAbort | Self::RebaseAbort => "abort", + Self::RebaseSkip => "skip", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperationKind { RefreshStatus, @@ -66,6 +93,11 @@ pub enum OperationKind { CreateBranch, MergeFastForward, Rebase, + MergeContinue, + MergeAbort, + RebaseContinue, + RebaseAbort, + RebaseSkip, OpenPullRequest, } @@ -88,6 +120,11 @@ impl OperationKind { Self::CreateBranch => "create branch", Self::MergeFastForward => "merge", Self::Rebase => "rebase", + Self::MergeContinue => "merge continue", + Self::MergeAbort => "merge abort", + Self::RebaseContinue => "rebase continue", + Self::RebaseAbort => "rebase abort", + Self::RebaseSkip => "rebase skip", Self::OpenPullRequest => "open pull request", } } @@ -111,6 +148,11 @@ impl OperationKind { Self::CreateBranch => "create_branch", Self::MergeFastForward => "merge_ff_only", Self::Rebase => "rebase", + Self::MergeContinue => "merge_continue", + Self::MergeAbort => "merge_abort", + Self::RebaseContinue => "rebase_continue", + Self::RebaseAbort => "rebase_abort", + Self::RebaseSkip => "rebase_skip", Self::OpenPullRequest => "open_pull_request", } } diff --git a/crates/bitbygit-core/src/prompt_parser.rs b/crates/bitbygit-core/src/prompt_parser.rs index 5a17a43..03420a5 100644 --- a/crates/bitbygit-core/src/prompt_parser.rs +++ b/crates/bitbygit-core/src/prompt_parser.rs @@ -1,10 +1,10 @@ use std::error::Error; use std::fmt; -use crate::OperationRequest; +use crate::{OperationRequest, RecoveryRequest}; -const PROMPT_EXAMPLES: &str = "branches, checkout , branch , branch from , merge , rebase , open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; -pub const UNSUPPORTED_PROMPT_MESSAGE: &str = "Unsupported prompt. Try: branches, checkout , branch , branch from , merge , rebase , open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; +const PROMPT_EXAMPLES: &str = "branches, checkout , branch , branch from , merge , merge --continue, merge --abort, rebase , rebase --continue, rebase --abort, rebase --skip, open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; +pub const UNSUPPORTED_PROMPT_MESSAGE: &str = "Unsupported prompt. Try: branches, checkout , branch , branch from , merge , merge --continue, merge --abort, rebase , rebase --continue, rebase --abort, rebase --skip, open pr, commit -m \"message\", fetch, push, pull, or pull --rebase"; const SHELL_SYNTAX_MESSAGE: &str = "Shell-style prompt syntax is not supported. Use one guarded prompt at a time."; const RAW_GIT_MESSAGE: &str = @@ -171,6 +171,14 @@ fn parse_single_prompt(input: &str) -> Result Ok(OperationRequest::OpenPullRequest { base: None }), "pull" => Ok(OperationRequest::Pull { rebase: false }), "pull --rebase" | "pull rebase" => Ok(OperationRequest::Pull { rebase: true }), + "merge --continue" => Ok(OperationRequest::Recover(RecoveryRequest::MergeContinue)), + "merge --abort" => Ok(OperationRequest::Recover(RecoveryRequest::MergeAbort)), + "rebase --continue" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseContinue)), + "rebase --abort" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseAbort)), + "rebase --skip" => Ok(OperationRequest::Recover(RecoveryRequest::RebaseSkip)), + "merge --skip" => Err(parse_error( + "Merge skip is not supported by Git. Use merge --continue or merge --abort.", + )), _ if lower.starts_with("checkout ") => parse_one_arg_prompt(trimmed, "checkout") .map(|branch| OperationRequest::Checkout { branch }), _ if lower.starts_with("merge ") => { @@ -353,6 +361,26 @@ mod tests { ("pull", OperationRequest::Pull { rebase: false }), ("pull --rebase", OperationRequest::Pull { rebase: true }), ("pull rebase", OperationRequest::Pull { rebase: true }), + ( + "merge --continue", + OperationRequest::Recover(RecoveryRequest::MergeContinue), + ), + ( + "MERGE --ABORT", + OperationRequest::Recover(RecoveryRequest::MergeAbort), + ), + ( + "rebase --continue", + OperationRequest::Recover(RecoveryRequest::RebaseContinue), + ), + ( + "rebase --abort", + OperationRequest::Recover(RecoveryRequest::RebaseAbort), + ), + ( + "rebase --skip", + OperationRequest::Recover(RecoveryRequest::RebaseSkip), + ), ( "checkout feature/auth", OperationRequest::Checkout { @@ -462,6 +490,11 @@ mod tests { ), ("push --force", UNSUPPORTED_PROMPT_MESSAGE.to_owned()), ("pull --ff-only", UNSUPPORTED_PROMPT_MESSAGE.to_owned()), + ( + "merge --skip", + "Merge skip is not supported by Git. Use merge --continue or merge --abort." + .to_owned(), + ), ("git push", RAW_GIT_MESSAGE.to_owned()), ("git commit -m \"message\"", RAW_GIT_MESSAGE.to_owned()), ( diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index ed42fd8..4555db2 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -258,6 +258,24 @@ impl Git { ]) } + pub fn recover_exact( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + expected_state: &RecoveryState, + ) -> Result { + if self.recovery_state()? != *expected_state { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed after preview", + operation.label(), + action.label() + ), + }); + } + self.recover(operation, action) + } + pub fn push_current_branch( &self, remote: &str, @@ -686,6 +704,20 @@ impl Git { Ok(status) } + pub fn recovery_state(&self) -> Result { + Ok(RecoveryState { + operation: self.repository_operation()?, + head: self.head_target()?, + status: self + .run_raw(["status", "--porcelain=v2", "--branch", "-z"])? + .stdout, + index: self.run_raw(["ls-files", "--stage", "-z"])?.stdout, + worktree_diff: self + .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? + .stdout, + }) + } + pub fn stage_path(&self, path: &Path) -> Result { self.run_path_args(["add"], Some(path), true) } @@ -1346,6 +1378,15 @@ pub enum RecoveryAction { Skip, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryState { + operation: Option, + head: HeadTarget, + status: Vec, + index: Vec, + worktree_diff: Vec, +} + impl RecoveryAction { fn label(self) -> &'static str { match self { @@ -2421,6 +2462,24 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_state_changed_after_preview() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + repo.write("conflict.txt", "changed after preview\n")?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed recovery state to be rejected".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[cfg(unix)] #[test] fn merge_continue_runs_hooks_and_does_not_bypass_signing() -> Result<(), Box> { diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 7dc5878..0021b95 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -18,13 +18,14 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; use bitbygit_core::{ ConfirmationRequirement, OperationKind, OperationPlan, OperationRequest, OperationStep, - RiskLevel, + RecoveryRequest, RiskLevel, prompt_parser::{ParsedPrompt, parse_prompt}, }; use bitbygit_gh::{CreatePullRequest, GhError, GitHub}; use bitbygit_git::{ BranchInfo, BranchKind, BranchState, BranchTarget, ChangeKind, Git, GitError, GitOutput, Head, - HeadTarget, RepositoryOperation, StatusEntry, StatusEntryType, + HeadTarget, RecoveryAction as GitRecoveryAction, RecoveryState, RepositoryOperation, + StatusEntry, StatusEntryType, }; use bitbygit_store::{AuditEntry, LocalStore, RepoId, StorePaths}; @@ -703,6 +704,9 @@ enum PendingPayload { base: BranchTarget, target: HeadTarget, }, + Recovery { + state: RecoveryState, + }, OpenPullRequest { branch: String, upstream: String, @@ -1231,6 +1235,44 @@ fn rebase_plan(current: &str, base: &BranchTarget) -> OperationPlan { ) } +fn recovery_plan(request: RecoveryRequest) -> OperationPlan { + let operation = request.operation_label(); + let action = request.action_label(); + OperationPlan::new( + OperationRequest::Recover(request), + format!("{} {} plan", capitalize(operation), action), + vec![ + OperationStep::new( + recovery_operation_kind(request), + RiskLevel::High, + format!("{action} active {operation}"), + ) + .with_detail("block if repository state changes after preview"), + ], + format!( + "Explicit confirmation required: press uppercase Y to {action} the {operation} or n to cancel." + ), + ) +} + +fn recovery_operation_kind(request: RecoveryRequest) -> OperationKind { + match request { + RecoveryRequest::MergeContinue => OperationKind::MergeContinue, + RecoveryRequest::MergeAbort => OperationKind::MergeAbort, + RecoveryRequest::RebaseContinue => OperationKind::RebaseContinue, + RecoveryRequest::RebaseAbort => OperationKind::RebaseAbort, + RecoveryRequest::RebaseSkip => OperationKind::RebaseSkip, + } +} + +fn capitalize(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_ascii_uppercase().to_string() + characters.as_str()) + .unwrap_or_default() +} + fn open_pull_request_plan( request: OperationRequest, remote: &str, @@ -1448,6 +1490,15 @@ fn prompt_sequence_request_preview( RiskLevel::High, format!("rebase current branch onto {base}"), )), + OperationRequest::Recover(request) => Ok(PromptSequenceStepPreview::new( + recovery_operation_kind(*request), + RiskLevel::High, + format!( + "{} active {}", + request.action_label(), + request.operation_label() + ), + )), OperationRequest::OpenPullRequest { base } => { let preview = PromptSequenceStepPreview::new( OperationKind::OpenPullRequest, @@ -1595,6 +1646,7 @@ impl OperationPlanner { } fn plan_request(&self, request: OperationRequest) -> Result { + self.ensure_conflict_mode_allowed(std::slice::from_ref(&request))?; match request { OperationRequest::RefreshStatus => { Err("Refresh status is handled directly by the UI.".to_owned()) @@ -1633,6 +1685,7 @@ impl OperationPlanner { } OperationRequest::Merge { branch } => self.plan_merge(branch), OperationRequest::Rebase { base } => self.plan_rebase(base), + OperationRequest::Recover(request) => self.plan_recovery(request), OperationRequest::OpenPullRequest { base } => self.plan_open_pull_request(base), OperationRequest::PromptSequence { .. } => { Err("Prompt sequences are handled by prompt submission.".to_owned()) @@ -1647,6 +1700,7 @@ impl OperationPlanner { if requests.len() < 2 { return Err("Prompt sequence requires at least two steps.".to_owned()); } + self.ensure_conflict_mode_allowed(&requests)?; for (index, request) in requests.iter().enumerate() { prompt_sequence_request_preview(request).map_err(|error| { format!("Prompt sequence step {} is blocked: {error}", index + 1) @@ -1962,6 +2016,34 @@ impl OperationPlanner { )) } + fn plan_recovery(&self, request: RecoveryRequest) -> Result { + let git = self.git(); + let status = git + .status() + .map_err(|error| format!("Unable to prepare recovery plan: {error}"))?; + let operation = recovery_git_operation(request); + if status.operation != Some(operation) { + return Err(recovery_state_error(request, status.operation)); + } + if matches!( + request, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) && !status.conflicted_files().is_empty() + { + return Err(format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(request.operation_label()) + )); + } + let state = git + .recovery_state() + .map_err(|error| format!("Unable to snapshot recovery state: {error}"))?; + Ok(PreparedOperation::new( + recovery_plan(request), + ExecutionContext::from_payload(PendingPayload::Recovery { state }), + )) + } + fn plan_open_pull_request( &self, requested_base: Option, @@ -2102,6 +2184,32 @@ impl OperationPlanner { branch_name(&status.branch).map_err(|error| error.replace("Operation", action)) } + fn ensure_conflict_mode_allowed(&self, requests: &[OperationRequest]) -> Result<(), String> { + let operation = self + .git() + .status() + .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? + .operation; + + for request in requests { + if let OperationRequest::Recover(recovery) = request { + if operation != Some(recovery_git_operation(*recovery)) { + return Err(recovery_state_error(*recovery, operation)); + } + } + if let Some(active) = operation + && !conflict_mode_allows(request) + { + return Err(format!( + "{} blocked: an active {} must be resolved first.", + request_action_label(request), + operation_label(active).to_ascii_lowercase() + )); + } + } + Ok(()) + } + fn default_remote_name(&self) -> Option { let remotes = self.git().remotes().ok()?; remotes @@ -2137,6 +2245,85 @@ impl OperationPlanner { } } +fn conflict_mode_allows(request: &OperationRequest) -> bool { + matches!( + request, + OperationRequest::RefreshStatus + | OperationRequest::ViewDiff { .. } + | OperationRequest::Fetch + | OperationRequest::StagePaths { .. } + | OperationRequest::UnstagePaths { .. } + | OperationRequest::StageAll + | OperationRequest::UnstageAll + | OperationRequest::Branches + | OperationRequest::Recover(_) + ) +} + +fn request_action_label(request: &OperationRequest) -> String { + match request { + OperationRequest::RefreshStatus => "Refresh status", + OperationRequest::ViewDiff { .. } => "View diff", + OperationRequest::Fetch => "Fetch", + OperationRequest::StagePaths { .. } => "Stage", + OperationRequest::UnstagePaths { .. } => "Unstage", + OperationRequest::StageAll => "Stage all", + OperationRequest::UnstageAll => "Unstage all", + OperationRequest::Commit { .. } => "Commit", + OperationRequest::Push => "Push", + OperationRequest::Pull { rebase: false } => "Pull", + OperationRequest::Pull { rebase: true } => "Pull rebase", + OperationRequest::Branches => "Branches", + OperationRequest::Checkout { .. } => "Checkout", + OperationRequest::CreateBranch { .. } => "Create branch", + OperationRequest::Merge { .. } => "Merge", + OperationRequest::Rebase { .. } => "Rebase", + OperationRequest::Recover(request) => { + return format!( + "{} {}", + capitalize(request.operation_label()), + request.action_label() + ); + } + OperationRequest::OpenPullRequest { .. } => "Open pull request", + OperationRequest::PromptSequence { .. } => "Prompt sequence", + } + .to_owned() +} + +fn recovery_git_operation(request: RecoveryRequest) -> RepositoryOperation { + match request { + RecoveryRequest::MergeContinue | RecoveryRequest::MergeAbort => RepositoryOperation::Merge, + RecoveryRequest::RebaseContinue + | RecoveryRequest::RebaseAbort + | RecoveryRequest::RebaseSkip => RepositoryOperation::Rebase, + } +} + +fn recovery_git_action(request: RecoveryRequest) -> GitRecoveryAction { + match request { + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue => { + GitRecoveryAction::Continue + } + RecoveryRequest::MergeAbort | RecoveryRequest::RebaseAbort => GitRecoveryAction::Abort, + RecoveryRequest::RebaseSkip => GitRecoveryAction::Skip, + } +} + +fn recovery_state_error(request: RecoveryRequest, active: Option) -> String { + let action = request_action_label(&OperationRequest::Recover(request)); + match active { + Some(active) => format!( + "{action} blocked: an active {} does not match the requested recovery.", + operation_label(active).to_ascii_lowercase() + ), + None => format!( + "{action} blocked: no active {} exists.", + request.operation_label() + ), + } +} + fn open_pull_request_gh_error(error: GhError) -> String { format!("Open pull request blocked: {error}") } @@ -2731,6 +2918,11 @@ impl PlanExecutor { OperationKind::CreateBranch => self.run_create_branch_step(plan, context, &git), OperationKind::MergeFastForward => self.run_merge_step(plan, context, &git), OperationKind::Rebase => self.run_rebase_step(plan, context, &git), + OperationKind::MergeContinue + | OperationKind::MergeAbort + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip => self.run_recovery_step(plan, step, context, &git), OperationKind::OpenPullRequest => self.run_open_pull_request_step(plan, context, &git), OperationKind::RefreshStatus | OperationKind::ViewDiff => { Err(StepExecutionError::Unsupported(format!( @@ -3012,6 +3204,33 @@ impl PlanExecutor { git_output(git.rebase_onto(target, head)) } + fn run_recovery_step( + &self, + plan: &OperationPlan, + step: &OperationStep, + context: &ExecutionContext, + git: &Git, + ) -> StepRunResult { + let OperationRequest::Recover(request) = &plan.request else { + return Err(StepExecutionError::Unsupported( + "recovery step requires a typed recovery request".to_owned(), + )); + }; + if step.kind != recovery_operation_kind(*request) { + return Err(StepExecutionError::Unsupported( + "recovery step request does not match its operation kind".to_owned(), + )); + } + let PendingPayload::Recovery { state } = typed_payload(context, step.kind)? else { + return Err(mismatched_context(step.kind)); + }; + git_output(git.recover_exact( + recovery_git_operation(*request), + recovery_git_action(*request), + state, + )) + } + fn run_open_pull_request_step( &self, plan: &OperationPlan, @@ -3729,6 +3948,11 @@ fn should_show_git_output(kind: OperationKind) -> bool { | OperationKind::CreateBranch | OperationKind::MergeFastForward | OperationKind::Rebase + | OperationKind::MergeContinue + | OperationKind::MergeAbort + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip ) } @@ -4536,6 +4760,176 @@ mod tests { Ok(()) } + #[test] + fn recovery_plans_are_typed_high_risk_and_use_stable_audit_names() { + let cases = [ + ( + RecoveryRequest::MergeContinue, + OperationKind::MergeContinue, + "merge_continue", + ), + ( + RecoveryRequest::MergeAbort, + OperationKind::MergeAbort, + "merge_abort", + ), + ( + RecoveryRequest::RebaseContinue, + OperationKind::RebaseContinue, + "rebase_continue", + ), + ( + RecoveryRequest::RebaseAbort, + OperationKind::RebaseAbort, + "rebase_abort", + ), + ( + RecoveryRequest::RebaseSkip, + OperationKind::RebaseSkip, + "rebase_skip", + ), + ]; + + for (request, kind, audit_name) in cases { + let plan = recovery_plan(request); + assert_eq!(plan.request, OperationRequest::Recover(request)); + assert_eq!(plan.steps[0].kind, kind); + assert_eq!(plan.confirmation.risk_level, RiskLevel::High); + assert_eq!( + plan.confirmation.requirement, + ConfirmationRequirement::ExplicitConfirmation + ); + assert!(plan.confirmation.prompt.contains("uppercase Y")); + assert_eq!(kind.audit_operation(), audit_name); + } + } + + #[test] + fn conflict_mode_blocks_risky_requests_before_sequence_side_effects() + -> Result<(), Box> { + let repo = merge_conflict_repo("conflict-policy")?; + std::fs::write(repo.join("unrelated.txt"), "do not stage\n")?; + let planner = OperationPlanner::new(&repo); + + assert!(planner.plan_request(OperationRequest::StageAll).is_ok()); + assert!(planner.plan_request(OperationRequest::Branches).is_ok()); + assert!( + planner + .plan_request(OperationRequest::RefreshStatus) + .is_err_and(|error| error.contains("handled directly by the UI")) + ); + assert!( + planner + .plan_request(OperationRequest::ViewDiff { + path: "conflict.txt".to_owned(), + }) + .is_err_and(|error| error.contains("handled directly by the UI")) + ); + assert!( + planner + .plan_request(OperationRequest::Push) + .is_err_and(|error| error.contains("active merge")) + ); + + let Err(error) = + planner.plan_prompt_sequence(vec![OperationRequest::StageAll, OperationRequest::Push]) + else { + return Err("risky deferred step should block the whole sequence".into()); + }; + assert!(error.contains("active merge")); + assert!( + git_stdout( + &repo, + &["diff", "--cached", "--name-only", "--", "unrelated.txt"] + )? + .trim() + .is_empty() + ); + Ok(()) + } + + #[test] + fn recovery_planner_matches_active_state_and_continue_prerequisites() + -> Result<(), Box> { + let repo = merge_conflict_repo("recovery-planner")?; + let planner = OperationPlanner::new(repo); + + let operation = planner + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + assert_eq!( + operation.plan.confirmation.requirement, + ConfirmationRequirement::ExplicitConfirmation + ); + assert!(matches!( + operation.context.payload, + Some(PendingPayload::Recovery { .. }) + )); + assert!( + planner + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeContinue)) + .is_err_and(|error| error.contains("resolve and stage all conflicts")) + ); + assert!( + planner + .plan_request(OperationRequest::Recover(RecoveryRequest::RebaseAbort)) + .is_err_and(|error| error.contains("does not match")) + ); + Ok(()) + } + + #[test] + fn recovery_execution_revalidates_state_and_audits_safely() -> Result<(), Box> { + let repo = merge_conflict_repo("recovery-revalidation")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + std::fs::write(repo.join("conflict.txt"), "changed after preview\n")?; + let paths = isolated_store_paths("recovery-revalidation-audit")?; + + let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) + .execute(&operation.plan, operation.context); + + assert!(!execution.succeeded()); + assert!(execution.message().contains("state changed after preview")); + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + assert_eq!(entries[1].result, "error"); + assert!( + !entries[1] + .message + .contains(&repo.to_string_lossy().to_string()) + ); + Ok(()) + } + + #[test] + fn confirmed_recovery_executes_and_records_stable_audit_entries() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-audit")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + let paths = isolated_store_paths("recovery-audit")?; + + let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) + .execute(&operation.plan, operation.context); + + assert!(execution.succeeded(), "{}", execution.message()); + assert_eq!(Git::new(&repo).status()?.operation, None); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + assert_eq!(entries[0].message, "pending"); + assert_eq!(entries[1].message, "completed"); + Ok(()) + } + #[test] fn open_pull_request_plan_previews_target_and_creates_pull_request() -> Result<(), Box> { @@ -6192,6 +6586,35 @@ mod tests { Ok(root) } + fn merge_conflict_repo(name: &str) -> Result> { + let repo = isolated_git_repo(name)?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let base = git_stdout(&repo, &["branch", "--show-current"])?; + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", base.trim()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["merge", "conflicting"]) + .output()?; + if output.status.success() { + return Err(std::io::Error::other("expected merge conflict").into()); + } + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(repo) + } + fn isolated_bare_git_repo(name: &str) -> Result> { let root = isolated_temp_root(name)?; let output = std::process::Command::new("git") From 0fdb1e4c6ceeefd84e4a267bb2e83a09c4533fe7 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 14:53:29 -0400 Subject: [PATCH 02/53] fix: revalidate conflict recovery state --- crates/bitbygit-git/src/lib.rs | 139 +++++++++++++++++++++++++++++++++ crates/bitbygit-tui/src/lib.rs | 113 +++++++++++++++++++++------ 2 files changed, 229 insertions(+), 23 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 4555db2..e36e6b2 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -20,6 +20,17 @@ const COMMIT_HOOKS: &[&str] = &[ "commit-msg", "post-commit", ]; +const RECOVERY_METADATA_PATHS: &[&str] = &[ + "MERGE_HEAD", + "MERGE_MSG", + "MERGE_MODE", + "MERGE_AUTOSTASH", + "AUTO_MERGE", + "ORIG_HEAD", + "REBASE_HEAD", + "rebase-merge", + "rebase-apply", +]; #[derive(Debug, Clone)] pub struct Git { @@ -715,9 +726,22 @@ impl Git { worktree_diff: self .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? .stdout, + metadata: self.recovery_metadata()?, }) } + fn recovery_metadata(&self) -> Result, GitError> { + let mut entries = Vec::new(); + for relative in RECOVERY_METADATA_PATHS { + snapshot_recovery_metadata( + Path::new(relative), + &self.git_path(relative)?, + &mut entries, + )?; + } + Ok(entries) + } + pub fn stage_path(&self, path: &Path) -> Result { self.run_path_args(["add"], Some(path), true) } @@ -1179,6 +1203,64 @@ impl Git { } } +fn snapshot_recovery_metadata( + relative: &Path, + path: &Path, + entries: &mut Vec, +) -> Result<(), GitError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + entries.push(RecoveryMetadataEntry { + path: relative.to_owned(), + value: RecoveryMetadataValue::Missing, + }); + return Ok(()); + } + Err(source) => return Err(recovery_metadata_io_error(relative, source)), + }; + let file_type = metadata.file_type(); + let value = if file_type.is_dir() { + RecoveryMetadataValue::Directory + } else if file_type.is_file() { + RecoveryMetadataValue::File( + fs::read(path).map_err(|source| recovery_metadata_io_error(relative, source))?, + ) + } else if file_type.is_symlink() { + RecoveryMetadataValue::Symlink( + fs::read_link(path).map_err(|source| recovery_metadata_io_error(relative, source))?, + ) + } else { + RecoveryMetadataValue::Other + }; + entries.push(RecoveryMetadataEntry { + path: relative.to_owned(), + value, + }); + + if file_type.is_dir() { + let mut children = fs::read_dir(path) + .map_err(|source| recovery_metadata_io_error(relative, source))? + .collect::, _>>() + .map_err(|source| recovery_metadata_io_error(relative, source))?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + snapshot_recovery_metadata(&relative.join(child.file_name()), &child.path(), entries)?; + } + } + Ok(()) +} + +fn recovery_metadata_io_error(path: &Path, source: std::io::Error) -> GitError { + GitError::Io { + args: vec![ + "snapshot-recovery-metadata".to_owned(), + path.to_string_lossy().into_owned(), + ], + source, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Repository { pub root: PathBuf, @@ -1385,6 +1467,22 @@ pub struct RecoveryState { status: Vec, index: Vec, worktree_diff: Vec, + metadata: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecoveryMetadataEntry { + path: PathBuf, + value: RecoveryMetadataValue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecoveryMetadataValue { + Missing, + Directory, + File(Vec), + Symlink(PathBuf), + Other, } impl RecoveryAction { @@ -2480,6 +2578,47 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_changed_merge_metadata() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + fs::write(git.git_path("MERGE_MSG")?, "changed merge message\n")?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed merge metadata to be rejected".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[test] + fn exact_recovery_rejects_changed_rebase_metadata() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let todo = git.git_path("rebase-merge/git-rebase-todo")?; + let mut changed_todo = fs::read(&todo)?; + changed_todo.extend_from_slice(b"# changed after preview\n"); + fs::write(todo, changed_todo)?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed rebase metadata to be rejected".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + #[cfg(unix)] #[test] fn merge_continue_runs_hooks_and_does_not_bypass_signing() -> Result<(), Box> { diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 0021b95..bccf49d 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -2185,29 +2185,7 @@ impl OperationPlanner { } fn ensure_conflict_mode_allowed(&self, requests: &[OperationRequest]) -> Result<(), String> { - let operation = self - .git() - .status() - .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? - .operation; - - for request in requests { - if let OperationRequest::Recover(recovery) = request { - if operation != Some(recovery_git_operation(*recovery)) { - return Err(recovery_state_error(*recovery, operation)); - } - } - if let Some(active) = operation - && !conflict_mode_allows(request) - { - return Err(format!( - "{} blocked: an active {} must be resolved first.", - request_action_label(request), - operation_label(active).to_ascii_lowercase() - )); - } - } - Ok(()) + validate_conflict_mode(&self.git(), requests) } fn default_remote_name(&self) -> Option { @@ -2245,6 +2223,36 @@ impl OperationPlanner { } } +fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<(), String> { + if requests.iter().all(|request| { + conflict_mode_allows(request) && !matches!(request, OperationRequest::Recover(_)) + }) { + return Ok(()); + } + let operation = git + .status() + .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? + .operation; + + for request in requests { + if let OperationRequest::Recover(recovery) = request + && operation != Some(recovery_git_operation(*recovery)) + { + return Err(recovery_state_error(*recovery, operation)); + } + if let Some(active) = operation + && !conflict_mode_allows(request) + { + return Err(format!( + "{} blocked: an active {} must be resolved first.", + request_action_label(request), + operation_label(active).to_ascii_lowercase() + )); + } + } + Ok(()) +} + fn conflict_mode_allows(request: &OperationRequest) -> bool { matches!( request, @@ -2838,6 +2846,14 @@ impl PlanExecutor { plan_error: Some("operation plan has no steps".to_owned()), }; } + if let Err(error) = validate_conflict_mode(&self.git(), std::slice::from_ref(&plan.request)) + { + return PlanExecutionResult { + planned_step_count: plan.steps.len(), + step_results: Vec::new(), + plan_error: Some(error), + }; + } let mut step_results = Vec::new(); for step in &plan.steps { @@ -4848,6 +4864,57 @@ mod tests { Ok(()) } + #[test] + fn queued_sequence_revalidates_conflict_mode_before_first_side_effect() + -> Result<(), Box> { + let repo = isolated_git_repo("queued-conflict-policy")?; + let remote = isolated_bare_git_repo("queued-conflict-policy-remote")?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let branch = git_stdout(&repo, &["branch", "--show-current"])? + .trim() + .to_owned(); + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", branch.as_str()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + git_stdout( + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()], + )?; + git_stdout(&repo, &["push", "-u", "origin", branch.as_str()])?; + let remote_ref = format!("refs/heads/{branch}"); + let remote_before = git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?; + std::fs::write(repo.join("ahead.txt"), "local only\n")?; + git_stdout(&repo, &["add", "ahead.txt"])?; + git_stdout(&repo, &["commit", "-m", "ahead"])?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![OperationRequest::Push, OperationRequest::Branches]) + .map_err(std::io::Error::other)?; + + let merge = std::process::Command::new("git") + .current_dir(&repo) + .args(["merge", "conflicting"]) + .output()?; + assert!(!merge.status.success()); + let paths = isolated_store_paths("queued-conflict-policy-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("active merge")); + assert_eq!( + git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?, + remote_before + ); + assert!(LocalStore::open(paths)?.list_audit_entries()?.is_empty()); + Ok(()) + } + #[test] fn recovery_planner_matches_active_state_and_continue_prerequisites() -> Result<(), Box> { From c8f0f9aa22d0c66d3ba764388ae77213fd35d375 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 15:02:12 -0400 Subject: [PATCH 03/53] fix: protect rebase recovery refs --- crates/bitbygit-git/src/lib.rs | 82 +++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index e36e6b2..14cd9ae 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -716,8 +716,9 @@ impl Git { } pub fn recovery_state(&self) -> Result { + let operation = self.repository_operation()?; Ok(RecoveryState { - operation: self.repository_operation()?, + operation, head: self.head_target()?, status: self .run_raw(["status", "--porcelain=v2", "--branch", "-z"])? @@ -727,6 +728,7 @@ impl Git { .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? .stdout, metadata: self.recovery_metadata()?, + refs: self.recovery_refs(operation)?, }) } @@ -742,6 +744,32 @@ impl Git { Ok(entries) } + fn recovery_refs(&self, operation: Option) -> Result, GitError> { + if operation != Some(RepositoryOperation::Rebase) { + return Ok(Vec::new()); + } + + let mut args = vec![ + OsString::from("for-each-ref"), + OsString::from("--format=%(refname)%00%(objectname)%00%(symref)"), + OsString::from("refs/rewritten"), + ]; + for relative in ["rebase-merge/head-name", "rebase-apply/head-name"] { + match fs::read(self.git_path(relative)?) { + Ok(reference) => { + let reference = strip_byte_line_ending(&reference); + if reference.starts_with(b"refs/") { + args.push(path_from_bytes(reference).into_os_string()); + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(recovery_metadata_io_error(Path::new(relative), source)), + } + } + + Ok(self.run_os_args(args, None, false)?.stdout) + } + pub fn stage_path(&self, path: &Path) -> Result { self.run_path_args(["add"], Some(path), true) } @@ -1468,6 +1496,7 @@ pub struct RecoveryState { index: Vec, worktree_diff: Vec, metadata: Vec, + refs: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2619,6 +2648,57 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_abort_preserves_branch_changed_after_preview() -> Result<(), Box> { + let (repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); + assert_ne!(newer_head, original_head); + repo.run(["update-ref", "refs/heads/topic", &newer_head])?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed rebase branch to block abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), + newer_head + ); + Ok(()) + } + + #[test] + fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); + repo.run(["update-ref", "refs/rewritten/concurrent", &newer_head])?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed rewritten ref to block recovery".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!( + repo.git_stdout(["rev-parse", "refs/rewritten/concurrent"])? + .trim(), + newer_head + ); + Ok(()) + } + #[cfg(unix)] #[test] fn merge_continue_runs_hooks_and_does_not_bypass_signing() -> Result<(), Box> { From 168f0dcb4303d1aaad14b7367cf5ad0f202389c2 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 15:11:09 -0400 Subject: [PATCH 04/53] fix: preflight deferred sequence policy --- crates/bitbygit-tui/src/lib.rs | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index bccf49d..4f79101 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -3495,6 +3495,19 @@ impl PromptSequenceExecutor { }; let mut step_results = Vec::with_capacity(total_steps); + let requests = std::iter::once(&sequence.first.plan.request) + .chain(sequence.remaining_requests.iter()) + .cloned() + .collect::>(); + if let Err(error) = validate_conflict_mode(&executor.git(), &requests) { + step_results.push(PromptSequenceStepResult::planning_failed( + 1, + prompt_sequence_request_title(&sequence.first.plan.request), + error, + )); + return PromptSequenceExecutionResult::new(total_steps, step_results); + } + let first = Self::execute_prepared_step(&executor, 1, sequence.first); let first_succeeded = first.succeeded; step_results.push(first); @@ -4915,6 +4928,79 @@ mod tests { Ok(()) } + #[test] + fn queued_sequence_revalidates_deferred_requests_before_first_side_effect() + -> Result<(), Box> { + let repo = isolated_git_repo("queued-deferred-conflict-policy")?; + let remote = isolated_bare_git_repo("queued-deferred-conflict-policy-remote")?; + configure_git_identity(&repo)?; + configure_git_identity(&remote)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let branch = git_stdout(&repo, &["branch", "--show-current"])? + .trim() + .to_owned(); + git_stdout( + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()], + )?; + git_stdout(&repo, &["push", "-u", "origin", branch.as_str()])?; + let remote_ref = format!("refs/heads/{branch}"); + let tracking_ref = format!("refs/remotes/origin/{branch}"); + let tracking_before = git_stdout(&repo, &["rev-parse", tracking_ref.as_str()])?; + + git_stdout(&repo, &["checkout", "-b", "conflicting"])?; + std::fs::write(repo.join("conflict.txt"), "other\n")?; + git_stdout(&repo, &["commit", "-am", "other"])?; + git_stdout(&repo, &["checkout", branch.as_str()])?; + std::fs::write(repo.join("conflict.txt"), "current\n")?; + git_stdout(&repo, &["commit", "-am", "current"])?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![OperationRequest::Fetch, OperationRequest::Push]) + .map_err(std::io::Error::other)?; + + let remote_tree = git_stdout(&remote, &["rev-parse", "HEAD^{tree}"])?; + let remote_head = git_stdout(&remote, &["rev-parse", remote_ref.as_str()])?; + let remote_update = git_stdout( + &remote, + &[ + "commit-tree", + remote_tree.trim(), + "-p", + remote_head.trim(), + "-m", + "remote update", + ], + )?; + git_stdout( + &remote, + &[ + "update-ref", + remote_ref.as_str(), + remote_update.trim(), + remote_head.trim(), + ], + )?; + let merge = std::process::Command::new("git") + .current_dir(&repo) + .args(["merge", "conflicting"]) + .output()?; + assert!(!merge.status.success()); + let paths = isolated_store_paths("queued-deferred-conflict-policy-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("active merge")); + assert_eq!( + git_stdout(&repo, &["rev-parse", tracking_ref.as_str()])?, + tracking_before + ); + assert!(LocalStore::open(paths)?.list_audit_entries()?.is_empty()); + Ok(()) + } + #[test] fn recovery_planner_matches_active_state_and_continue_prerequisites() -> Result<(), Box> { From 025b34fea89f7e6d3ee4216e54c84a3790aa4ab6 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 15:24:32 -0400 Subject: [PATCH 05/53] fix: protect ignored recovery data --- crates/bitbygit-git/src/lib.rs | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 14cd9ae..0e340d4 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -727,11 +727,36 @@ impl Git { worktree_diff: self .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? .stdout, + ignored_worktree: self.ignored_worktree()?, metadata: self.recovery_metadata()?, refs: self.recovery_refs(operation)?, }) } + fn ignored_worktree(&self) -> Result, GitError> { + let root = self.repo_root()?; + let paths = self + .run_raw([ + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--full-name", + "-z", + "--", + ])? + .stdout; + let mut entries = Vec::new(); + for path in paths + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let relative = path_from_bytes(path); + snapshot_recovery_metadata(&relative, &root.join(&relative), &mut entries)?; + } + Ok(entries) + } + fn recovery_metadata(&self) -> Result, GitError> { let mut entries = Vec::new(); for relative in RECOVERY_METADATA_PATHS { @@ -1495,6 +1520,7 @@ pub struct RecoveryState { status: Vec, index: Vec, worktree_diff: Vec, + ignored_worktree: Vec, metadata: Vec, refs: Vec, } @@ -2674,6 +2700,53 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_abort_preserves_ignored_file_changed_after_preview() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitignore", "target/\n")?; + repo.run(["add", ".gitignore"])?; + repo.run(["commit", "-m", "ignore target"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("first.txt", "first\n")?; + repo.run(["add", "first.txt"])?; + repo.run(["commit", "-m", "first"])?; + repo.write(".gitignore", "")?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "committed\n")?; + repo.run(["add", ".gitignore", "target/victim.bin"])?; + repo.run(["commit", "-m", "track victim"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "before preview\n")?; + let expected = git.recovery_state()?; + repo.write("target/victim.bin", "changed after preview\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed ignored file to block rebase abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + fs::read_to_string(repo.path().join("target/victim.bin"))?, + "changed after preview\n" + ); + Ok(()) + } + #[test] fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_merge_conflict()?; From 691dd2feae240cc34cb5f781fa0d63f4452c1839 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 16:06:40 -0400 Subject: [PATCH 06/53] fix: bound exact recovery snapshots --- crates/bitbygit-git/src/lib.rs | 224 ++++++++++++++++++++++++++++++--- 1 file changed, 207 insertions(+), 17 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 0e340d4..9b4daeb 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::error::Error; use std::ffi::OsString; @@ -727,15 +727,26 @@ impl Git { worktree_diff: self .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? .stdout, - ignored_worktree: self.ignored_worktree()?, + untracked_worktree: self.untracked_worktree()?, metadata: self.recovery_metadata()?, refs: self.recovery_refs(operation)?, }) } - fn ignored_worktree(&self) -> Result, GitError> { + fn untracked_worktree(&self) -> Result, GitError> { let root = self.repo_root()?; - let paths = self + let mut paths = BTreeSet::new(); + let nonignored = self + .run_raw([ + "ls-files", + "--others", + "--exclude-standard", + "--full-name", + "-z", + "--", + ])? + .stdout; + let ignored = self .run_raw([ "ls-files", "--others", @@ -746,17 +757,65 @@ impl Git { "--", ])? .stdout; + for output in [nonignored, ignored] { + paths.extend( + output + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(path_from_bytes), + ); + } + let mut entries = Vec::new(); - for path in paths - .split(|byte| *byte == 0) - .filter(|path| !path.is_empty()) - { - let relative = path_from_bytes(path); - snapshot_recovery_metadata(&relative, &root.join(&relative), &mut entries)?; + for relative in paths { + self.snapshot_recovery_worktree(&relative, &root.join(&relative), &mut entries)?; } Ok(entries) } + fn snapshot_recovery_worktree( + &self, + relative: &Path, + path: &Path, + entries: &mut Vec, + ) -> Result<(), GitError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + entries.push(RecoveryWorktreeEntry { + path: relative.to_owned(), + value: RecoveryWorktreeValue::Missing, + }); + return Ok(()); + } + Err(source) => return Err(recovery_metadata_io_error(relative, source)), + }; + let file_type = metadata.file_type(); + let value = if file_type.is_file() { + let oid = self + .run_path_args(["hash-object", "--no-filters"], Some(path), true)? + .stdout + .trim() + .to_owned(); + RecoveryWorktreeValue::File { + size: metadata.len(), + oid, + } + } else if file_type.is_symlink() { + RecoveryWorktreeValue::Symlink( + fs::read_link(path) + .map_err(|source| recovery_metadata_io_error(relative, source))?, + ) + } else { + RecoveryWorktreeValue::Other + }; + entries.push(RecoveryWorktreeEntry { + path: relative.to_owned(), + value, + }); + Ok(()) + } + fn recovery_metadata(&self) -> Result, GitError> { let mut entries = Vec::new(); for relative in RECOVERY_METADATA_PATHS { @@ -774,23 +833,41 @@ impl Git { return Ok(Vec::new()); } - let mut args = vec![ - OsString::from("for-each-ref"), - OsString::from("--format=%(refname)%00%(objectname)%00%(symref)"), - OsString::from("refs/rewritten"), - ]; + let mut references = BTreeSet::new(); for relative in ["rebase-merge/head-name", "rebase-apply/head-name"] { match fs::read(self.git_path(relative)?) { Ok(reference) => { let reference = strip_byte_line_ending(&reference); if reference.starts_with(b"refs/") { - args.push(path_from_bytes(reference).into_os_string()); + references.insert(path_from_bytes(reference).into_os_string()); } } Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} Err(source) => return Err(recovery_metadata_io_error(Path::new(relative), source)), } } + for relative in ["rebase-merge/update-refs", "rebase-apply/update-refs"] { + match fs::read(self.git_path(relative)?) { + Ok(contents) => { + references.extend( + contents + .split(|byte| *byte == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) + .filter(|line| line.starts_with(b"refs/")) + .map(|line| path_from_bytes(line).into_os_string()), + ); + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(recovery_metadata_io_error(Path::new(relative), source)), + } + } + + let mut args = vec![ + OsString::from("for-each-ref"), + OsString::from("--format=%(refname)%00%(objectname)%00%(symref)"), + OsString::from("refs/rewritten"), + ]; + args.extend(references); Ok(self.run_os_args(args, None, false)?.stdout) } @@ -1520,11 +1597,25 @@ pub struct RecoveryState { status: Vec, index: Vec, worktree_diff: Vec, - ignored_worktree: Vec, + untracked_worktree: Vec, metadata: Vec, refs: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecoveryWorktreeEntry { + path: PathBuf, + value: RecoveryWorktreeValue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecoveryWorktreeValue { + Missing, + File { size: u64, oid: String }, + Symlink(PathBuf), + Other, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RecoveryMetadataEntry { path: PathBuf, @@ -2700,6 +2791,55 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_continue_rejects_changed_update_refs_branch() -> Result<(), Box> { + let repo = initialized_repo()?; + repo.run(["switch", "-c", "topic"])?; + repo.write("first.txt", "first\n")?; + repo.run(["add", "first.txt"])?; + repo.run(["commit", "-m", "first"])?; + repo.run(["branch", "side"])?; + repo.write("second.txt", "second\n")?; + repo.run(["add", "second.txt"])?; + repo.run(["commit", "-m", "second"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--update-refs", "--exec", "false", "main"])?; + + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + let update_refs = fs::read(git.git_path("rebase-merge/update-refs")?)?; + assert!( + update_refs + .split(|byte| *byte == b'\n') + .any(|line| line == b"refs/heads/side") + ); + let expected = git.recovery_state()?; + let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); + let moved_side = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); + repo.run(["update-ref", "refs/heads/side", &moved_side])?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected changed update-refs branch to block rebase continue".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(repo.git_stdout(["rev-parse", "HEAD"])?.trim(), preview_head); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + repo.git_stdout(["rev-parse", "refs/heads/side"])?.trim(), + moved_side + ); + Ok(()) + } + #[test] fn exact_rebase_abort_preserves_ignored_file_changed_after_preview() -> Result<(), Box> { @@ -2747,6 +2887,56 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_changed_untracked_file_content() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("notes.txt", "before preview\n")?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + repo.write("notes.txt", "changed after preview\n")?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed untracked file to block merge abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + fs::read_to_string(repo.path().join("notes.txt"))?, + "changed after preview\n" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_state_fingerprints_large_sparse_ignored_file_without_retaining_content() + -> Result<(), Box> { + const SPARSE_FILE_SIZE: u64 = 256 * 1024 * 1024; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + fs::write(git.git_path("info/exclude")?, "large.bin\n")?; + let sparse_path = repo.path().join("large.bin"); + fs::File::create(&sparse_path)?.set_len(SPARSE_FILE_SIZE)?; + + let state = git.recovery_state()?; + let sparse_entry = state + .untracked_worktree + .iter() + .find(|entry| entry.path == Path::new("large.bin")) + .ok_or("missing sparse ignored file fingerprint")?; + let RecoveryWorktreeValue::File { size, oid } = &sparse_entry.value else { + return Err("expected sparse ignored file fingerprint".into()); + }; + assert_eq!(*size, SPARSE_FILE_SIZE); + assert!(oid.len() <= 64); + assert_eq!(git.recovery_state()?, state); + Ok(()) + } + #[test] fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_merge_conflict()?; From 433d8c0b24911effa6886b4cb2413bef3262992d Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 16:42:35 -0400 Subject: [PATCH 07/53] fix: fail closed on bounded recovery guards --- Cargo.lock | 75 ++ crates/bitbygit-git/Cargo.toml | 6 + crates/bitbygit-git/src/lib.rs | 1202 ++++++++++++++++++++++++++------ 3 files changed, 1064 insertions(+), 219 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61733df..5bb121b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,10 @@ dependencies = [ [[package]] name = "bitbygit-git" version = "0.1.0" +dependencies = [ + "libc", + "sha2", +] [[package]] name = "bitbygit-store" @@ -58,6 +62,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -93,6 +106,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -118,6 +140,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -153,6 +185,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.16.0" @@ -187,6 +229,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -452,6 +504,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -534,6 +597,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -569,6 +638,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 9867516..5993160 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -8,3 +8,9 @@ rust-version.workspace = true [lints] workspace = true + +[dependencies] +sha2 = "0.10" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 9b4daeb..767cc6a 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -4,10 +4,13 @@ use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus}; +use std::process::{Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; +use sha2::{Digest, Sha256}; + #[cfg(unix)] use std::os::unix::ffi::OsStringExt; @@ -31,6 +34,14 @@ const RECOVERY_METADATA_PATHS: &[&str] = &[ "rebase-merge", "rebase-apply", ]; +const MAX_RECOVERY_RETAINED_BYTES: usize = 8 * 1024 * 1024; +const MAX_RECOVERY_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +const MAX_RECOVERY_FILE_BYTES_READ: u64 = 64 * 1024 * 1024; +const MAX_RECOVERY_RELEVANT_FILES: usize = 10_000; +const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; +const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; +const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; +const MAX_RECOVERY_SUBPROCESSES: usize = 12; #[derive(Debug, Clone)] pub struct Git { @@ -275,7 +286,8 @@ impl Git { action: RecoveryAction, expected_state: &RecoveryState, ) -> Result { - if self.recovery_state()? != *expected_state { + let current_state = self.recovery_state()?; + if current_state != *expected_state { return Err(GitError::Blocked { message: format!( "{} {} is blocked because repository state changed after preview", @@ -284,6 +296,16 @@ impl Git { ), }); } + let final_state = self.recovery_state()?; + if final_state != current_state { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed during final recovery validation", + operation.label(), + action.label() + ), + }); + } self.recover(operation, action) } @@ -716,149 +738,224 @@ impl Git { } pub fn recovery_state(&self) -> Result { - let operation = self.repository_operation()?; + let mut capture = RecoveryCapture::default(); + let git_dir = path_from_bytes(strip_byte_line_ending( + &capture.required(self, &["rev-parse", "--absolute-git-dir"])?, + )); + let root = path_from_bytes(strip_byte_line_ending( + &capture.required(self, &["rev-parse", "--show-toplevel"])?, + )); + let operation = recovery_operation_at(&git_dir); + let head_oid = capture + .optional(self, &["rev-parse", "--verify", "--quiet", "HEAD"])? + .map(|output| String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned()); + let head_reference = capture + .optional(self, &["symbolic-ref", "--quiet", "HEAD"])? + .map(|output| String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned()); + let metadata = self.recovery_metadata(&git_dir, &mut capture)?; + let original_head = match operation { + Some(RepositoryOperation::Rebase) => Some(rebase_original_head(&metadata)?), + Some(RepositoryOperation::Merge) => capture + .optional(self, &["rev-parse", "--verify", "--quiet", "ORIG_HEAD"])? + .map(|output| { + String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned() + }), + None => None, + }; + let mut paths = BTreeSet::new(); + let changed_paths = + capture.required(self, &["diff", "--raw", "-z", "--no-abbrev", "HEAD", "--"])?; + parse_recovery_changed_paths(&changed_paths, &mut paths)?; + if let Some(original_head) = original_head { + let destination_paths = capture.required( + self, + &[ + "diff", + "--raw", + "-z", + "--no-abbrev", + "HEAD", + &original_head, + "--", + ], + )?; + parse_recovery_changed_paths(&destination_paths, &mut paths)?; + if operation == Some(RepositoryOperation::Rebase) { + let range = format!("{}..{original_head}", head_oid.as_deref().unwrap_or("HEAD")); + let changed_paths = capture.required( + self, + &[ + "log", + "--format=%x00", + "--raw", + "-z", + "--no-abbrev", + "--diff-merges=first-parent", + &range, + "--", + ], + )?; + parse_recovery_changed_paths(&changed_paths, &mut paths)?; + } + } + let path_bytes = paths + .iter() + .map(|path| path.as_os_str().as_encoded_bytes().len()) + .sum::(); + if path_bytes > MAX_RECOVERY_PATH_BYTES { + return Err(recovery_bound_error(format!( + "relevant path bytes exceed {MAX_RECOVERY_PATH_BYTES}" + ))); + } + capture.retain( + path_bytes.saturating_add(paths.len() * 96), + "relevant paths", + )?; + + let mut index_args = vec![ + OsString::from("ls-files"), + OsString::from("--stage"), + OsString::from("-z"), + OsString::from("--"), + ]; + index_args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); + let index = capture.required_os(self, index_args)?; + capture.retain(index.len(), "index output")?; + + let worktree = self.snapshot_recovery_worktree(&root, paths, &mut capture)?; + run_recovery_capture_hook(&self.cwd); + let refs = self.recovery_refs(operation, &metadata, &mut capture)?; + capture.retain(refs.len(), "recovery refs")?; + Ok(RecoveryState { operation, - head: self.head_target()?, - status: self - .run_raw(["status", "--porcelain=v2", "--branch", "-z"])? - .stdout, - index: self.run_raw(["ls-files", "--stage", "-z"])?.stdout, - worktree_diff: self - .run_raw(["diff", "--binary", "--no-ext-diff", "--"])? - .stdout, - untracked_worktree: self.untracked_worktree()?, - metadata: self.recovery_metadata()?, - refs: self.recovery_refs(operation)?, + head: HeadTarget { + oid: head_oid, + reference: head_reference, + }, + index, + worktree, + metadata, + refs, }) } - fn untracked_worktree(&self) -> Result, GitError> { - let root = self.repo_root()?; - let mut paths = BTreeSet::new(); - let nonignored = self - .run_raw([ - "ls-files", - "--others", - "--exclude-standard", - "--full-name", - "-z", - "--", - ])? - .stdout; - let ignored = self - .run_raw([ - "ls-files", - "--others", - "--ignored", - "--exclude-standard", - "--full-name", - "-z", - "--", - ])? - .stdout; - for output in [nonignored, ignored] { - paths.extend( - output - .split(|byte| *byte == 0) - .filter(|path| !path.is_empty()) - .map(path_from_bytes), - ); - } - - let mut entries = Vec::new(); + fn snapshot_recovery_worktree( + &self, + root: &Path, + paths: BTreeSet, + capture: &mut RecoveryCapture, + ) -> Result, GitError> { + let mut entries = Vec::with_capacity(paths.len()); for relative in paths { - self.snapshot_recovery_worktree(&relative, &root.join(&relative), &mut entries)?; + let path = root.join(&relative); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + entries.push(RecoveryWorktreeEntry { + path: relative.to_owned(), + value: RecoveryWorktreeValue::Missing, + }); + continue; + } + Err(source) => return Err(recovery_metadata_io_error(&relative, source)), + }; + let file_type = metadata.file_type(); + let value = if file_type.is_file() { + let (file, opened_metadata) = + open_recovery_regular_file(&path, &relative, &metadata)?; + let size = opened_metadata.len(); + capture.reserve_file_bytes(size)?; + let file = BufReader::new(file); + let mut file = file.take(size.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut bytes_read = 0_u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|source| recovery_metadata_io_error(&relative, source))?; + if read == 0 { + break; + } + bytes_read = bytes_read.saturating_add(read as u64); + if bytes_read > size { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because relevant file {} changed while it was fingerprinted", + relative.display() + ), + }); + } + hasher.update(&buffer[..read]); + } + ensure_recovery_regular_file_path_unchanged(&path, &relative, &opened_metadata)?; + RecoveryWorktreeValue::File { + size, + mode: recovery_file_mode(&opened_metadata), + identity: recovery_file_identity(&opened_metadata), + digest: hasher.finalize().into(), + } + } else if file_type.is_symlink() { + RecoveryWorktreeValue::Symlink( + fs::read_link(&path) + .map_err(|source| recovery_metadata_io_error(&relative, source))?, + ) + } else if file_type.is_dir() { + RecoveryWorktreeValue::Directory + } else { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because relevant path {} has an unsupported filesystem type", + relative.display() + ), + }); + }; + entries.push(RecoveryWorktreeEntry { + path: relative, + value, + }); } + capture.retain(entries.len() * 80, "worktree fingerprints")?; Ok(entries) } - fn snapshot_recovery_worktree( + fn recovery_metadata( &self, - relative: &Path, - path: &Path, - entries: &mut Vec, - ) -> Result<(), GitError> { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - entries.push(RecoveryWorktreeEntry { - path: relative.to_owned(), - value: RecoveryWorktreeValue::Missing, - }); - return Ok(()); - } - Err(source) => return Err(recovery_metadata_io_error(relative, source)), - }; - let file_type = metadata.file_type(); - let value = if file_type.is_file() { - let oid = self - .run_path_args(["hash-object", "--no-filters"], Some(path), true)? - .stdout - .trim() - .to_owned(); - RecoveryWorktreeValue::File { - size: metadata.len(), - oid, - } - } else if file_type.is_symlink() { - RecoveryWorktreeValue::Symlink( - fs::read_link(path) - .map_err(|source| recovery_metadata_io_error(relative, source))?, - ) - } else { - RecoveryWorktreeValue::Other - }; - entries.push(RecoveryWorktreeEntry { - path: relative.to_owned(), - value, - }); - Ok(()) - } - - fn recovery_metadata(&self) -> Result, GitError> { - let mut entries = Vec::new(); - for relative in RECOVERY_METADATA_PATHS { - snapshot_recovery_metadata( - Path::new(relative), - &self.git_path(relative)?, - &mut entries, - )?; - } - Ok(entries) + git_dir: &Path, + capture: &mut RecoveryCapture, + ) -> Result, GitError> { + snapshot_recovery_metadata(git_dir, capture) } - fn recovery_refs(&self, operation: Option) -> Result, GitError> { + fn recovery_refs( + &self, + operation: Option, + metadata: &[RecoveryMetadataEntry], + capture: &mut RecoveryCapture, + ) -> Result, GitError> { if operation != Some(RepositoryOperation::Rebase) { return Ok(Vec::new()); } let mut references = BTreeSet::new(); for relative in ["rebase-merge/head-name", "rebase-apply/head-name"] { - match fs::read(self.git_path(relative)?) { - Ok(reference) => { - let reference = strip_byte_line_ending(&reference); - if reference.starts_with(b"refs/") { - references.insert(path_from_bytes(reference).into_os_string()); - } + if let Some(reference) = recovery_metadata_file(metadata, relative) { + let reference = strip_byte_line_ending(reference); + if reference.starts_with(b"refs/") { + references.insert(path_from_bytes(reference).into_os_string()); } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(recovery_metadata_io_error(Path::new(relative), source)), } } for relative in ["rebase-merge/update-refs", "rebase-apply/update-refs"] { - match fs::read(self.git_path(relative)?) { - Ok(contents) => { - references.extend( - contents - .split(|byte| *byte == b'\n') - .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) - .filter(|line| line.starts_with(b"refs/")) - .map(|line| path_from_bytes(line).into_os_string()), - ); - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(recovery_metadata_io_error(Path::new(relative), source)), + if let Some(contents) = recovery_metadata_file(metadata, relative) { + references.extend( + contents + .split(|byte| *byte == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) + .filter(|line| line.starts_with(b"refs/")) + .map(|line| path_from_bytes(line).into_os_string()), + ); } } @@ -869,7 +966,7 @@ impl Git { ]; args.extend(references); - Ok(self.run_os_args(args, None, false)?.stdout) + capture.required_os(self, args) } pub fn stage_path(&self, path: &Path) -> Result { @@ -1333,52 +1430,517 @@ impl Git { } } -fn snapshot_recovery_metadata( +#[derive(Default)] +struct RecoveryCapture { + output_bytes: usize, + retained_bytes: usize, + file_bytes_read: u64, + metadata_bytes: usize, + metadata_entries: usize, + subprocesses: usize, +} + +impl RecoveryCapture { + fn required(&mut self, git: &Git, args: &[&str]) -> Result, GitError> { + self.command(git, args.iter().map(OsString::from).collect(), false)? + .ok_or_else(|| GitError::Parse { + message: "required recovery guard command returned no output".to_owned(), + }) + } + + fn optional(&mut self, git: &Git, args: &[&str]) -> Result>, GitError> { + self.command(git, args.iter().map(OsString::from).collect(), true) + } + + fn required_os(&mut self, git: &Git, args: Vec) -> Result, GitError> { + self.command(git, args, false)? + .ok_or_else(|| GitError::Parse { + message: "required recovery guard command returned no output".to_owned(), + }) + } + + fn command( + &mut self, + git: &Git, + args: Vec, + allow_missing: bool, + ) -> Result>, GitError> { + self.subprocesses += 1; + if self.subprocesses > MAX_RECOVERY_SUBPROCESSES { + return Err(recovery_bound_error(format!( + "subprocess count exceeds {MAX_RECOVERY_SUBPROCESSES}" + ))); + } + let display_args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let mut command = Command::new("git"); + command + .current_dir(&git.cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|source| GitError::Io { + args: display_args.clone(), + source, + })?; + let stdout = child.stdout.take().ok_or_else(|| GitError::Io { + args: display_args.clone(), + source: std::io::Error::other("recovery guard could not capture git stdout"), + })?; + let stderr = child.stderr.take().ok_or_else(|| GitError::Io { + args: display_args.clone(), + source: std::io::Error::other("recovery guard could not capture git stderr"), + })?; + let output_bytes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let stdout_bytes = std::sync::Arc::clone(&output_bytes); + let stderr_bytes = std::sync::Arc::clone(&output_bytes); + let stdout_reader = + std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes)); + let stderr_reader = + std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes)); + let status = loop { + if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { + let _ = child.kill(); + break child.wait(); + } + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), + Err(source) => break Err(source), + } + } + .map_err(|source| GitError::Io { + args: display_args.clone(), + source, + })?; + let stdout = stdout_reader + .join() + .map_err(|_| GitError::Io { + args: display_args.clone(), + source: std::io::Error::other("recovery guard stdout reader panicked"), + })? + .map_err(|source| GitError::Io { + args: display_args.clone(), + source, + })?; + let stderr = stderr_reader + .join() + .map_err(|_| GitError::Io { + args: display_args.clone(), + source: std::io::Error::other("recovery guard stderr reader panicked"), + })? + .map_err(|source| GitError::Io { + args: display_args.clone(), + source, + })?; + let bytes = output_bytes.load(std::sync::atomic::Ordering::Relaxed); + self.output_bytes = self.output_bytes.saturating_add(bytes); + if bytes > MAX_RECOVERY_OUTPUT_BYTES || self.output_bytes > MAX_RECOVERY_OUTPUT_BYTES { + return Err(recovery_bound_error(format!( + "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" + ))); + } + if status.success() { + return Ok(Some(stdout)); + } + if allow_missing && status.code() == Some(1) { + return Ok(None); + } + Err(GitError::GitFailed { + args: display_args, + status, + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }) + } + + fn retain(&mut self, bytes: usize, description: &str) -> Result<(), GitError> { + self.retained_bytes = self.retained_bytes.saturating_add(bytes); + if self.retained_bytes > MAX_RECOVERY_RETAINED_BYTES { + return Err(recovery_bound_error(format!( + "retained memory for {description} exceeds {MAX_RECOVERY_RETAINED_BYTES} bytes" + ))); + } + Ok(()) + } + + fn reserve_file_bytes(&mut self, bytes: u64) -> Result<(), GitError> { + self.file_bytes_read = self.file_bytes_read.saturating_add(bytes); + if self.file_bytes_read > MAX_RECOVERY_FILE_BYTES_READ { + return Err(recovery_bound_error(format!( + "file content bytes read exceed {MAX_RECOVERY_FILE_BYTES_READ}" + ))); + } + Ok(()) + } + + fn reserve_metadata_entry(&mut self) -> Result<(), GitError> { + if self.metadata_entries == MAX_RECOVERY_METADATA_ENTRIES { + return Err(recovery_bound_error(format!( + "metadata entries exceed {MAX_RECOVERY_METADATA_ENTRIES}" + ))); + } + self.metadata_entries += 1; + Ok(()) + } + + fn reserve_metadata_bytes(&mut self, bytes: usize) -> Result<(), GitError> { + self.metadata_bytes = self.metadata_bytes.saturating_add(bytes); + if self.metadata_bytes > MAX_RECOVERY_METADATA_BYTES { + return Err(recovery_bound_error(format!( + "metadata bytes exceed {MAX_RECOVERY_METADATA_BYTES}" + ))); + } + self.retain(bytes.saturating_add(80), "recovery metadata") + } +} + +fn read_bounded_recovery_output( + mut stream: impl Read, + total: std::sync::Arc, +) -> std::io::Result> { + let mut retained = Vec::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = stream.read(&mut buffer)?; + if read == 0 { + return Ok(retained); + } + let previous = total.fetch_add(read, std::sync::atomic::Ordering::Relaxed); + if previous <= MAX_RECOVERY_OUTPUT_BYTES { + let keep = read.min(MAX_RECOVERY_OUTPUT_BYTES + 1 - previous); + retained.extend_from_slice(&buffer[..keep]); + } + if previous.saturating_add(read) > MAX_RECOVERY_OUTPUT_BYTES { + return Ok(retained); + } + } +} + +fn recovery_bound_error(detail: String) -> GitError { + GitError::Blocked { + message: format!("recovery is blocked because the recovery guard {detail}"), + } +} + +fn parse_recovery_changed_paths( + output: &[u8], + paths: &mut BTreeSet, +) -> Result<(), GitError> { + let mut expected_paths = 0; + for record in output.split(|byte| *byte == 0) { + if expected_paths > 0 { + if paths.len() == MAX_RECOVERY_RELEVANT_FILES { + return Err(recovery_bound_error(format!( + "relevant file count exceeds {MAX_RECOVERY_RELEVANT_FILES}" + ))); + } + paths.insert(path_from_bytes(record)); + expected_paths -= 1; + continue; + } + let header = record.strip_prefix(b"\n").unwrap_or(record); + if !header.starts_with(b":") { + continue; + } + let status = header + .rsplit(|byte| *byte == b' ') + .next() + .and_then(|status| status.first()) + .ok_or_else(|| GitError::Parse { + message: "recovery changed-path record has no status".to_owned(), + })?; + expected_paths = usize::from(matches!(status, b'R' | b'C')) + 1; + } + if expected_paths != 0 { + return Err(GitError::Parse { + message: "recovery changed-path output ended before its path".to_owned(), + }); + } + Ok(()) +} + +fn recovery_operation_at(git_dir: &Path) -> Option { + let rebase_apply = git_dir.join("rebase-apply"); + if git_dir.join("rebase-merge").exists() + || (rebase_apply.exists() && !rebase_apply.join("applying").exists()) + { + Some(RepositoryOperation::Rebase) + } else if git_dir.join("MERGE_HEAD").exists() { + Some(RepositoryOperation::Merge) + } else { + None + } +} + +#[cfg(unix)] +fn recovery_file_mode(metadata: &fs::Metadata) -> u32 { + use std::os::unix::fs::PermissionsExt; + + metadata.permissions().mode() +} + +#[cfg(not(unix))] +fn recovery_file_mode(metadata: &fs::Metadata) -> u32 { + u32::from(metadata.permissions().readonly()) +} + +#[cfg(unix)] +fn recovery_file_identity(metadata: &fs::Metadata) -> (u64, u64) { + use std::os::unix::fs::MetadataExt; + + (metadata.dev(), metadata.ino()) +} + +#[cfg(not(unix))] +fn recovery_file_identity(_metadata: &fs::Metadata) -> (u64, u64) { + (0, 0) +} + +fn open_recovery_regular_file( + path: &Path, relative: &Path, + expected: &fs::Metadata, +) -> Result<(fs::File, fs::Metadata), GitError> { + run_recovery_file_open_hook(path); + + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + + fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + }; + #[cfg(not(unix))] + let file = fs::File::open(path); + + let file = file.map_err(|source| GitError::Blocked { + message: format!( + "recovery is blocked because regular file {} changed before it could be opened: {source}", + relative.display() + ), + })?; + let opened = file + .metadata() + .map_err(|source| recovery_metadata_io_error(relative, source))?; + if !opened.file_type().is_file() + || opened.len() != expected.len() + || recovery_file_mode(&opened) != recovery_file_mode(expected) + || recovery_file_identity(&opened) != recovery_file_identity(expected) + { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because regular file {} changed while it was opened", + relative.display() + ), + }); + } + Ok((file, opened)) +} + +fn ensure_recovery_regular_file_path_unchanged( path: &Path, - entries: &mut Vec, + relative: &Path, + opened: &fs::Metadata, ) -> Result<(), GitError> { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - entries.push(RecoveryMetadataEntry { - path: relative.to_owned(), - value: RecoveryMetadataValue::Missing, - }); - return Ok(()); + let current = fs::symlink_metadata(path).map_err(|source| GitError::Blocked { + message: format!( + "recovery is blocked because regular file {} changed while it was read: {source}", + relative.display() + ), + })?; + if !current.file_type().is_file() + || current.len() != opened.len() + || recovery_file_mode(¤t) != recovery_file_mode(opened) + || recovery_file_identity(¤t) != recovery_file_identity(opened) + { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because regular file {} changed while it was read", + relative.display() + ), + }); + } + Ok(()) +} + +#[cfg(test)] +type RecoveryCaptureHook = Box; + +#[cfg(test)] +static RECOVERY_CAPTURE_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(test)] +fn run_recovery_capture_hook(cwd: &Path) { + if let Ok(mut hook) = RECOVERY_CAPTURE_HOOK.lock() { + if hook.as_ref().is_some_and(|(target, _)| target == cwd) { + let Some((_, hook)) = hook.take() else { + return; + }; + hook(); + } + } +} + +#[cfg(not(test))] +fn run_recovery_capture_hook(_cwd: &Path) {} + +#[cfg(test)] +static RECOVERY_FILE_OPEN_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_file_open_hook(path: &Path) { + if let Ok(mut hooks) = RECOVERY_FILE_OPEN_HOOKS.lock() { + if let Some(index) = hooks.iter().position(|(target, _)| target == path) { + let (_, hook) = hooks.swap_remove(index); + hook(); } - Err(source) => return Err(recovery_metadata_io_error(relative, source)), + } +} + +#[cfg(not(test))] +fn run_recovery_file_open_hook(_path: &Path) {} + +fn recovery_metadata_file<'a>( + metadata: &'a [RecoveryMetadataEntry], + relative: &str, +) -> Option<&'a [u8]> { + metadata.iter().find_map(|entry| { + if entry.path == Path::new(relative) { + if let RecoveryMetadataValue::File(contents) = &entry.value { + return Some(contents.as_slice()); + } + } + None + }) +} + +fn rebase_original_head(metadata: &[RecoveryMetadataEntry]) -> Result { + let backends = ["rebase-merge", "rebase-apply"] + .into_iter() + .filter(|backend| { + metadata.iter().any(|entry| { + entry.path == Path::new(backend) && entry.value == RecoveryMetadataValue::Directory + }) + }) + .collect::>(); + let [backend] = backends.as_slice() else { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase backend is ambiguous" + .to_owned(), + }); }; - let file_type = metadata.file_type(); - let value = if file_type.is_dir() { - RecoveryMetadataValue::Directory - } else if file_type.is_file() { - RecoveryMetadataValue::File( - fs::read(path).map_err(|source| recovery_metadata_io_error(relative, source))?, - ) - } else if file_type.is_symlink() { - RecoveryMetadataValue::Symlink( - fs::read_link(path).map_err(|source| recovery_metadata_io_error(relative, source))?, - ) - } else { - RecoveryMetadataValue::Other + let relative = format!("{backend}/orig-head"); + let Some(contents) = recovery_metadata_file(metadata, &relative) else { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {relative} is unavailable"), + }); }; - entries.push(RecoveryMetadataEntry { - path: relative.to_owned(), - value, - }); + let oid = strip_byte_line_ending(contents); + if !matches!(oid.len(), 40 | 64) || !oid.iter().all(u8::is_ascii_hexdigit) { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {relative} is not a valid object id"), + }); + } + Ok(String::from_utf8_lossy(oid).into_owned()) +} - if file_type.is_dir() { - let mut children = fs::read_dir(path) - .map_err(|source| recovery_metadata_io_error(relative, source))? - .collect::, _>>() - .map_err(|source| recovery_metadata_io_error(relative, source))?; - children.sort_by_key(|entry| entry.file_name()); - for child in children { - snapshot_recovery_metadata(&relative.join(child.file_name()), &child.path(), entries)?; +fn snapshot_recovery_metadata( + git_dir: &Path, + capture: &mut RecoveryCapture, +) -> Result, GitError> { + let mut entries = Vec::new(); + let mut pending = Vec::new(); + for relative in RECOVERY_METADATA_PATHS.iter().rev() { + capture.reserve_metadata_entry()?; + pending.push((PathBuf::from(relative), git_dir.join(relative))); + } + + while let Some((relative, path)) = pending.pop() { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + capture.reserve_metadata_bytes(relative.as_os_str().as_encoded_bytes().len())?; + entries.push(RecoveryMetadataEntry { + path: relative, + value: RecoveryMetadataValue::Missing, + }); + continue; + } + Err(source) => return Err(recovery_metadata_io_error(&relative, source)), + }; + let file_type = metadata.file_type(); + let value = if file_type.is_dir() { + capture.reserve_metadata_bytes(relative.as_os_str().as_encoded_bytes().len())?; + RecoveryMetadataValue::Directory + } else if file_type.is_file() { + let maximum = MAX_RECOVERY_METADATA_BYTES.saturating_sub(capture.metadata_bytes); + let mut contents = Vec::new(); + let (file, opened_metadata) = open_recovery_regular_file(&path, &relative, &metadata)?; + file.take(maximum.saturating_add(1) as u64) + .read_to_end(&mut contents) + .map_err(|source| recovery_metadata_io_error(&relative, source))?; + ensure_recovery_regular_file_path_unchanged(&path, &relative, &opened_metadata)?; + if contents.len() > maximum { + return Err(recovery_bound_error(format!( + "metadata bytes exceed {MAX_RECOVERY_METADATA_BYTES}" + ))); + } + capture.reserve_metadata_bytes( + relative + .as_os_str() + .as_encoded_bytes() + .len() + .saturating_add(contents.len()), + )?; + RecoveryMetadataValue::File(contents) + } else if file_type.is_symlink() { + let target = fs::read_link(&path) + .map_err(|source| recovery_metadata_io_error(&relative, source))?; + capture.reserve_metadata_bytes( + relative + .as_os_str() + .as_encoded_bytes() + .len() + .saturating_add(target.as_os_str().as_encoded_bytes().len()), + )?; + RecoveryMetadataValue::Symlink(target) + } else { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because control metadata {} has an unsupported filesystem type", + relative.display() + ), + }); + }; + entries.push(RecoveryMetadataEntry { + path: relative.clone(), + value, + }); + + if file_type.is_dir() { + for child in fs::read_dir(&path) + .map_err(|source| recovery_metadata_io_error(&relative, source))? + { + let child = + child.map_err(|source| recovery_metadata_io_error(&relative, source))?; + capture.reserve_metadata_entry()?; + pending.push((relative.join(child.file_name()), child.path())); + } } } - Ok(()) + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(entries) } fn recovery_metadata_io_error(path: &Path, source: std::io::Error) -> GitError { @@ -1594,10 +2156,8 @@ pub enum RecoveryAction { pub struct RecoveryState { operation: Option, head: HeadTarget, - status: Vec, index: Vec, - worktree_diff: Vec, - untracked_worktree: Vec, + worktree: Vec, metadata: Vec, refs: Vec, } @@ -1611,9 +2171,14 @@ struct RecoveryWorktreeEntry { #[derive(Debug, Clone, PartialEq, Eq)] enum RecoveryWorktreeValue { Missing, - File { size: u64, oid: String }, + File { + size: u64, + mode: u32, + identity: (u64, u64), + digest: [u8; 32], + }, + Directory, Symlink(PathBuf), - Other, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1628,7 +2193,6 @@ enum RecoveryMetadataValue { Directory, File(Vec), Symlink(PathBuf), - Other, } impl RecoveryAction { @@ -2840,69 +3404,113 @@ mod tests { Ok(()) } + #[cfg(unix)] #[test] - fn exact_rebase_abort_preserves_ignored_file_changed_after_preview() - -> Result<(), Box> { - let repo = initialized_repo()?; - repo.write(".gitignore", "target/\n")?; - repo.run(["add", ".gitignore"])?; - repo.run(["commit", "-m", "ignore target"])?; - repo.run(["switch", "-c", "topic"])?; - repo.write("first.txt", "first\n")?; - repo.run(["add", "first.txt"])?; - repo.run(["commit", "-m", "first"])?; - repo.write(".gitignore", "")?; - fs::create_dir(repo.path().join("target"))?; - repo.write("target/victim.bin", "committed\n")?; - repo.run(["add", ".gitignore", "target/victim.bin"])?; - repo.run(["commit", "-m", "track victim"])?; - repo.run(["switch", "main"])?; - repo.write("upstream.txt", "upstream\n")?; - repo.run(["add", "upstream.txt"])?; - repo.run(["commit", "-m", "upstream"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + fn exact_rebase_abort_preserves_ignored_file_chmod_after_preview() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let repo = prepare_rebase_with_ignored_victim()?; let git = Git::new(repo.path()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - fs::create_dir(repo.path().join("target"))?; - repo.write("target/victim.bin", "before preview\n")?; + let victim = repo.path().join("target/victim.bin"); let expected = git.recovery_state()?; - repo.write("target/victim.bin", "changed after preview\n")?; + let mut permissions = fs::metadata(&victim)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&victim, permissions)?; let Err(error) = git.recover_exact( RepositoryOperation::Rebase, RecoveryAction::Abort, &expected, ) else { - return Err("expected changed ignored file to block rebase abort".into()); + return Err("expected ignored executable mode change to block rebase abort".into()); }; assert!(error.to_string().contains("state changed after preview")); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - assert_eq!( - fs::read_to_string(repo.path().join("target/victim.bin"))?, - "changed after preview\n" + assert_ne!(fs::metadata(victim)?.permissions().mode() & 0o111, 0); + Ok(()) + } + + #[test] + fn exact_rebase_uses_backend_orig_head_when_top_level_orig_head_is_wrong() + -> Result<(), Box> { + let repo = prepare_rebase_with_ignored_victim()?; + let git = Git::new(repo.path()); + let victim = repo.path().join("target/victim.bin"); + repo.run(["update-ref", "ORIG_HEAD", "HEAD"])?; + assert_ne!( + fs::read_to_string(git.git_path("rebase-merge/orig-head")?)?.trim(), + repo.git_stdout(["rev-parse", "ORIG_HEAD"])?.trim() ); + let expected = git.recovery_state()?; + fs::write(&victim, "changed after preview\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected ignored content change to block rebase abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(fs::read_to_string(victim)?, "changed after preview\n"); Ok(()) } #[test] - fn exact_recovery_rejects_changed_untracked_file_content() -> Result<(), Box> { + fn recovery_metadata_entry_bound_applies_before_wide_tree_traversal() + -> Result<(), Box> { + let repo = initialized_repo()?; + let git = Git::new(repo.path()); + let backend = git.git_path("rebase-merge")?; + fs::create_dir(&backend)?; + for index in 0..MAX_RECOVERY_METADATA_ENTRIES { + fs::write(backend.join(index.to_string()), [])?; + } + + let Err(error) = git.recovery_state() else { + return Err("expected wide recovery metadata tree to exceed entry bound".into()); + }; + assert!(error.to_string().contains("metadata entries")); + Ok(()) + } + + #[test] + fn changed_path_parser_stops_at_relevant_file_bound() -> Result<(), Box> { + let mut output = Vec::new(); + for index in 0..=MAX_RECOVERY_RELEVANT_FILES { + output.extend_from_slice(b":100644 100644 0000000 0000000 M\0"); + output.extend_from_slice(format!("path-{index}\0").as_bytes()); + } + let mut paths = BTreeSet::new(); + + let Err(error) = parse_recovery_changed_paths(&output, &mut paths) else { + return Err("expected changed paths to exceed relevant file bound".into()); + }; + assert!(error.to_string().contains("relevant file count")); + assert_eq!(paths.len(), MAX_RECOVERY_RELEVANT_FILES); + Ok(()) + } + + #[test] + fn exact_recovery_ignores_unrelated_untracked_tree() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; + fs::create_dir(repo.path().join("ignored"))?; + fs::write(repo.path().join(".git/info/exclude"), "ignored/\n")?; + for index in 0..=MAX_RECOVERY_RELEVANT_FILES { + repo.write(&format!("ignored/{index}"), "ignored\n")?; + } repo.write("notes.txt", "before preview\n")?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; repo.write("notes.txt", "changed after preview\n")?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected changed untracked file to block merge abort".into()); - }; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(git.status()?.operation, None); assert_eq!( fs::read_to_string(repo.path().join("notes.txt"))?, "changed after preview\n" @@ -2910,30 +3518,157 @@ mod tests { Ok(()) } + #[test] + fn recovery_state_blocks_large_tracked_binary_before_reading_it() -> Result<(), Box> + { + use std::io::Write; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let mut file = fs::File::options() + .write(true) + .truncate(true) + .open(repo.path().join("conflict.txt"))?; + let mut chunk = [0_u8; 64 * 1024]; + let mut value = 0x9e37_79b9_u32; + for byte in &mut chunk { + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + *byte = value as u8; + } + for _ in 0..(MAX_RECOVERY_FILE_BYTES_READ / chunk.len() as u64) { + file.write_all(&chunk)?; + } + file.write_all(&[1])?; + file.flush()?; + + let Err(error) = git.recovery_state() else { + return Err("expected large tracked file to exceed recovery read bound".into()); + }; + assert!(error.to_string().contains("file content bytes read")); + Ok(()) + } + + #[test] + fn exact_recovery_blocks_relevant_filesystem_type_replacement() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let conflict = repo.path().join("conflict.txt"); + fs::remove_file(&conflict)?; + fs::create_dir(&conflict)?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected filesystem type replacement to block recovery".into()); + }; + assert!(error.to_string().contains("state changed after preview")); + assert!(conflict.is_dir()); + Ok(()) + } + #[cfg(unix)] #[test] - fn recovery_state_fingerprints_large_sparse_ignored_file_without_retaining_content() + fn recovery_fingerprint_does_not_hang_on_fifo_replacement() -> Result<(), Box> { + use std::os::unix::fs::FileTypeExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let conflict = repo.path().join("conflict.txt"); + let fifo = repo.path().join("replacement-fifo"); + let status = Command::new("mkfifo").arg(&fifo).status()?; + if !status.success() { + return Err("mkfifo failed".into()); + } + let replacement = fifo.clone(); + let destination = conflict.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::rename(replacement, destination); + }); + RECOVERY_FILE_OPEN_HOOKS + .lock() + .map_err(|_| "recovery file-open hook lock poisoned")? + .push((conflict.clone(), hook)); + + let Err(error) = git.recovery_state() else { + return Err("expected FIFO replacement to block recovery guard".into()); + }; + assert!(matches!(error, GitError::Blocked { .. })); + assert!(fs::symlink_metadata(conflict)?.file_type().is_fifo()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_fingerprint_does_not_follow_symlink_replacement() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let conflict = repo.path().join("conflict.txt"); + let secret = repo.path().join("secret.txt"); + let replacement = repo.path().join("replacement-link"); + fs::write(&secret, "must not be fingerprinted\n")?; + symlink(&secret, &replacement)?; + let destination = conflict.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::rename(replacement, destination); + }); + RECOVERY_FILE_OPEN_HOOKS + .lock() + .map_err(|_| "recovery file-open hook lock poisoned")? + .push((conflict.clone(), hook)); + + let Err(error) = git.recovery_state() else { + return Err("expected symlink replacement to block recovery guard".into()); + }; + assert!(matches!(error, GitError::Blocked { .. })); + assert!(fs::symlink_metadata(conflict)?.file_type().is_symlink()); + assert_eq!(fs::read_to_string(secret)?, "must not be fingerprinted\n"); + Ok(()) + } + + #[test] + fn exact_recovery_repeats_guard_after_synchronized_concurrent_edit() -> Result<(), Box> { - const SPARSE_FILE_SIZE: u64 = 256 * 1024 * 1024; + use std::sync::mpsc; let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); - fs::write(git.git_path("info/exclude")?, "large.bin\n")?; - let sparse_path = repo.path().join("large.bin"); - fs::File::create(&sparse_path)?.set_len(SPARSE_FILE_SIZE)?; + let expected = git.recovery_state()?; + let conflict = repo.path().join("conflict.txt"); + let (scanned_tx, scanned_rx) = mpsc::channel(); + let (edited_tx, edited_rx) = mpsc::channel(); + let editor = std::thread::spawn(move || { + let _ = scanned_rx.recv(); + let result = fs::write(conflict, "concurrent edit\n"); + let _ = edited_tx.send(result); + }); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = scanned_tx.send(()); + let _ = edited_rx.recv(); + }); + *RECOVERY_CAPTURE_HOOK + .lock() + .map_err(|_| "recovery capture hook lock poisoned")? = Some((repo.path(), hook)); - let state = git.recovery_state()?; - let sparse_entry = state - .untracked_worktree - .iter() - .find(|entry| entry.path == Path::new("large.bin")) - .ok_or("missing sparse ignored file fingerprint")?; - let RecoveryWorktreeValue::File { size, oid } = &sparse_entry.value else { - return Err("expected sparse ignored file fingerprint".into()); + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected concurrent edit to block recovery".into()); }; - assert_eq!(*size, SPARSE_FILE_SIZE); - assert!(oid.len() <= 64); - assert_eq!(git.recovery_state()?, state); + editor.join().map_err(|_| "editor thread panicked")?; + assert!( + error + .to_string() + .contains("during final recovery validation") + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "concurrent edit\n" + ); Ok(()) } @@ -3875,6 +4610,35 @@ mod tests { Ok((repo, original_head)) } + fn prepare_rebase_with_ignored_victim() -> Result> { + let repo = initialized_repo()?; + repo.write(".gitignore", "target/\n")?; + repo.run(["add", ".gitignore"])?; + repo.run(["commit", "-m", "ignore target"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("first.txt", "first\n")?; + repo.run(["add", "first.txt"])?; + repo.run(["commit", "-m", "first"])?; + repo.write(".gitignore", "")?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "committed\n")?; + repo.run(["add", ".gitignore", "target/victim.bin"])?; + repo.run(["commit", "-m", "track victim"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "before preview\n")?; + assert_eq!( + Git::new(repo.path()).status()?.operation, + Some(RepositoryOperation::Rebase) + ); + Ok(repo) + } + fn prepare_two_conflict_rebase() -> Result> { let repo = initialized_repo()?; repo.write("first.txt", "base first\n")?; From 0595603b3b30214ecb730c7ae2870535e0935bc2 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 16:51:27 -0400 Subject: [PATCH 08/53] fix: protect recovery directory contents --- crates/bitbygit-git/src/lib.rs | 93 ++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 767cc6a..c0792e0 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -843,11 +843,16 @@ impl Git { fn snapshot_recovery_worktree( &self, root: &Path, - paths: BTreeSet, + mut paths: BTreeSet, capture: &mut RecoveryCapture, ) -> Result, GitError> { let mut entries = Vec::with_capacity(paths.len()); - for relative in paths { + let mut path_bytes = paths + .iter() + .map(|path| path.as_os_str().as_encoded_bytes().len()) + .sum::(); + let mut path_count = paths.len(); + while let Some(relative) = paths.pop_first() { let path = root.join(&relative); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, @@ -902,7 +907,33 @@ impl Git { .map_err(|source| recovery_metadata_io_error(&relative, source))?, ) } else if file_type.is_dir() { - RecoveryWorktreeValue::Directory + let children = fs::read_dir(&path) + .map_err(|source| recovery_metadata_io_error(&relative, source))?; + for child in children { + let child = + child.map_err(|source| recovery_metadata_io_error(&relative, source))?; + let child = relative.join(child.file_name()); + if paths.insert(child.clone()) { + if path_count == MAX_RECOVERY_RELEVANT_FILES { + return Err(recovery_bound_error(format!( + "relevant file count exceeds {MAX_RECOVERY_RELEVANT_FILES}" + ))); + } + path_count += 1; + let child_bytes = child.as_os_str().as_encoded_bytes().len(); + path_bytes = path_bytes.saturating_add(child_bytes); + if path_bytes > MAX_RECOVERY_PATH_BYTES { + return Err(recovery_bound_error(format!( + "relevant path bytes exceed {MAX_RECOVERY_PATH_BYTES}" + ))); + } + capture.retain(child_bytes.saturating_add(96), "relevant paths")?; + } + } + RecoveryWorktreeValue::Directory { + mode: recovery_file_mode(&metadata), + identity: recovery_file_identity(&metadata), + } } else { return Err(GitError::Blocked { message: format!( @@ -2177,7 +2208,10 @@ enum RecoveryWorktreeValue { identity: (u64, u64), digest: [u8; 32], }, - Directory, + Directory { + mode: u32, + identity: (u64, u64), + }, Symlink(PathBuf), } @@ -3433,6 +3467,31 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_abort_preserves_ignored_directory_child_changed_after_preview() + -> Result<(), Box> { + let repo = prepare_rebase_with_ignored_directory_collision()?; + let git = Git::new(repo.path()); + let victim = repo.path().join("victim"); + let child = victim.join("data"); + let expected = git.recovery_state()?; + fs::write(&child, "changed after preview\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected ignored directory child change to block rebase abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert!(victim.is_dir()); + assert_eq!(fs::read_to_string(child)?, "changed after preview\n"); + Ok(()) + } + #[test] fn exact_rebase_uses_backend_orig_head_when_top_level_orig_head_is_wrong() -> Result<(), Box> { @@ -4639,6 +4698,32 @@ mod tests { Ok(repo) } + fn prepare_rebase_with_ignored_directory_collision() -> Result> { + let repo = initialized_repo()?; + repo.run(["switch", "-c", "topic"])?; + repo.write(".gitignore", "victim/\n")?; + repo.write("first.txt", "first\n")?; + repo.run(["add", ".gitignore", "first.txt"])?; + repo.run(["commit", "-m", "ignore victim"])?; + repo.write(".gitignore", "")?; + repo.write("victim", "committed file\n")?; + repo.run(["add", ".gitignore", "victim"])?; + repo.run(["commit", "-m", "track victim file"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + fs::create_dir(repo.path().join("victim"))?; + repo.write("victim/data", "before preview\n")?; + assert_eq!( + Git::new(repo.path()).status()?.operation, + Some(RepositoryOperation::Rebase) + ); + Ok(repo) + } + fn prepare_two_conflict_rebase() -> Result> { let repo = initialized_repo()?; repo.write("first.txt", "base first\n")?; From 00e13d56c8dc2394dad8b73cc079d7819dbe7ef6 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 17:02:50 -0400 Subject: [PATCH 09/53] fix: fail closed at recovery execution --- crates/bitbygit-git/src/lib.rs | 102 +++++++++++++++++++++++++++++++-- crates/bitbygit-tui/src/lib.rs | 18 ++++-- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index c0792e0..862407c 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -230,7 +230,8 @@ impl Git { self.run_args(vec!["rebase".to_owned(), base.oid.clone()]) } - pub fn recover( + #[cfg(test)] + fn recover( &self, operation: RepositoryOperation, action: RecoveryAction, @@ -306,7 +307,14 @@ impl Git { ), }); } - self.recover(operation, action) + run_recovery_execution_hook(&self.cwd); + Err(GitError::Blocked { + message: format!( + "{} {} is blocked because Git cannot atomically fence recovery refs, metadata, index, and worktree state", + operation.label(), + action.label() + ), + }) } pub fn push_current_branch( @@ -1249,6 +1257,7 @@ impl Git { self.run_args_with_editor(args, false) } + #[cfg(test)] fn run_recovery_args(&self, args: Vec) -> Result { self.run_args_with_editor(args, true) } @@ -1825,6 +1834,23 @@ fn run_recovery_capture_hook(cwd: &Path) { #[cfg(not(test))] fn run_recovery_capture_hook(_cwd: &Path) {} +#[cfg(test)] +static RECOVERY_EXECUTION_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_execution_hook(cwd: &Path) { + if let Ok(mut hooks) = RECOVERY_EXECUTION_HOOKS.lock() { + if let Some(index) = hooks.iter().position(|(target, _)| target == cwd) { + let (_, hook) = hooks.swap_remove(index); + hook(); + } + } +} + +#[cfg(not(test))] +fn run_recovery_execution_hook(_cwd: &Path) {} + #[cfg(test)] static RECOVERY_FILE_OPEN_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3555,7 +3581,8 @@ mod tests { } #[test] - fn exact_recovery_ignores_unrelated_untracked_tree() -> Result<(), Box> { + fn exact_recovery_fails_closed_without_touching_unrelated_untracked_tree() + -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; fs::create_dir(repo.path().join("ignored"))?; fs::write(repo.path().join(".git/info/exclude"), "ignored/\n")?; @@ -3567,9 +3594,18 @@ mod tests { let expected = git.recovery_state()?; repo.write("notes.txt", "changed after preview\n")?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected exact recovery to fail closed".into()); + }; - assert_eq!(git.status()?.operation, None); + assert!( + error + .to_string() + .contains("cannot atomically fence recovery") + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); assert_eq!( fs::read_to_string(repo.path().join("notes.txt"))?, "changed after preview\n" @@ -3731,6 +3767,62 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_abort_fails_closed_on_branch_move_after_final_validation() + -> Result<(), Box> { + use std::sync::mpsc; + + let (repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); + assert_ne!(newer_head, original_head); + + let (validated_tx, validated_rx) = mpsc::channel(); + let (moved_tx, moved_rx) = mpsc::channel(); + let repo_path = repo.path(); + let moved_head = newer_head.clone(); + let mover = std::thread::spawn(move || { + let _ = validated_rx.recv(); + let output = Command::new("git") + .current_dir(repo_path) + .args(["update-ref", "refs/heads/topic", &moved_head]) + .output(); + let _ = moved_tx.send(()); + output + }); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = validated_tx.send(()); + let _ = moved_rx.recv(); + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((repo.path(), hook)); + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected exact rebase abort to fail closed".into()); + }; + let output = mover.join().map_err(|_| "branch mover thread panicked")??; + + assert!(output.status.success()); + assert!( + error + .to_string() + .contains("cannot atomically fence recovery") + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), + newer_head + ); + Ok(()) + } + #[test] fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_merge_conflict()?; diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 4f79101..e5722b1 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -5062,8 +5062,8 @@ mod tests { } #[test] - fn confirmed_recovery_executes_and_records_stable_audit_entries() -> Result<(), Box> - { + fn confirmed_recovery_fails_closed_and_records_stable_audit_entries() + -> Result<(), Box> { let repo = merge_conflict_repo("recovery-audit")?; let operation = OperationPlanner::new(&repo) .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) @@ -5073,13 +5073,21 @@ mod tests { let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) .execute(&operation.plan, operation.context); - assert!(execution.succeeded(), "{}", execution.message()); - assert_eq!(Git::new(&repo).status()?.operation, None); + assert!(!execution.succeeded()); + assert!( + execution + .message() + .contains("cannot atomically fence recovery") + ); + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); let entries = LocalStore::open(paths)?.list_audit_entries()?; assert_eq!(entries.len(), 2); assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); assert_eq!(entries[0].message, "pending"); - assert_eq!(entries[1].message, "completed"); + assert_eq!(entries[1].result, "error"); Ok(()) } From 9ae25d08cb1eb300d950ff7d792dd8b068cfe175 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 17:31:46 -0400 Subject: [PATCH 10/53] fix: execute recovery with ref fencing --- crates/bitbygit-git/src/lib.rs | 285 ++++++++++++++++++++++++++++++--- crates/bitbygit-tui/src/lib.rs | 84 ++++++++-- 2 files changed, 330 insertions(+), 39 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 862407c..839fa57 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -8,6 +8,7 @@ use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; +use std::sync::atomic::{AtomicU64, Ordering}; use sha2::{Digest, Sha256}; @@ -42,6 +43,7 @@ const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; const MAX_RECOVERY_SUBPROCESSES: usize = 12; +static NEXT_RECOVERY_HOOK_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone)] pub struct Git { @@ -308,13 +310,24 @@ impl Git { }); } run_recovery_execution_hook(&self.cwd); - Err(GitError::Blocked { - message: format!( - "{} {} is blocked because Git cannot atomically fence recovery refs, metadata, index, and worktree state", - operation.label(), - action.label() - ), - }) + let execution_state = self.recovery_state()?; + if execution_state != final_state { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed after final validation", + operation.label(), + action.label() + ), + }); + } + let hooks = RecoveryHooks::install(self, &execution_state)?; + self.run_recovery_args_with_hooks( + vec![ + operation.label().to_owned(), + format!("--{}", action.label()), + ], + &hooks.path, + ) } pub fn push_current_branch( @@ -1262,6 +1275,39 @@ impl Git { self.run_args_with_editor(args, true) } + fn run_recovery_args_with_hooks( + &self, + mut args: Vec, + hooks_path: &Path, + ) -> Result { + let display_args = args.clone(); + let hooks_path = hooks_path.to_str().ok_or_else(|| GitError::Blocked { + message: "recovery is blocked because its ref fence path is not valid UTF-8".to_owned(), + })?; + args.splice( + 0..0, + ["-c".to_owned(), format!("core.hooksPath={hooks_path}")], + ); + match self.run_args_with_editor(args, true) { + Err(GitError::GitFailed { + status, + stdout, + stderr, + .. + }) => Err(GitError::GitFailed { + args: display_args, + status, + stdout, + stderr, + }), + Err(GitError::Io { source, .. }) => Err(GitError::Io { + args: display_args, + source, + }), + result => result, + } + } + fn run_args_with_editor( &self, args: Vec, @@ -1470,6 +1516,143 @@ impl Git { } } +struct RecoveryHooks { + path: PathBuf, +} + +impl RecoveryHooks { + fn install(git: &Git, state: &RecoveryState) -> Result { + let git_dir = git.git_path("")?; + let id = NEXT_RECOVERY_HOOK_ID.fetch_add(1, Ordering::Relaxed); + let path = git_dir.join(format!( + "bitbygit-recovery-hooks-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).map_err(|source| recovery_hook_io_error("create", source))?; + let hooks = Self { path }; + + let configured_hook = git.hook_path("reference-transaction")?; + if let Some(configured_dir) = configured_hook.parent() { + match fs::read_dir(configured_dir) { + Ok(entries) => { + for entry in entries { + let entry = + entry.map_err(|source| recovery_hook_io_error("read", source))?; + if entry.file_name() == "reference-transaction" { + continue; + } + link_recovery_hook(&entry.path(), &hooks.path.join(entry.file_name()))?; + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(recovery_hook_io_error("read", source)), + } + } + + let expected_path = hooks.path.join("expected-refs"); + let mut expected = Vec::new(); + for (reference, oid) in recovery_ref_values(state)? { + expected.extend_from_slice(oid.as_bytes()); + expected.push(b'\t'); + expected.extend_from_slice(reference.as_bytes()); + expected.push(b'\n'); + } + fs::write(&expected_path, expected) + .map_err(|source| recovery_hook_io_error("write", source))?; + + let transaction_input = hooks.path.join("transaction-input"); + let user_hook = hook_is_enabled(&configured_hook).then_some(configured_hook); + let user_hook_command = user_hook + .as_ref() + .map(|hook| { + format!( + "{} \"$1\" < {} || exit $?\n", + shell_quote(&hook.to_string_lossy()), + shell_quote(&transaction_input.to_string_lossy()) + ) + }) + .unwrap_or_default(); + let script = format!( + "#!/bin/sh\ninput={}\ncat > \"$input\" || exit 1\n{}[ \"$1\" = prepared ] || exit 0\nwhile read -r old new ref\ndo\n while IFS='\t' read -r expected protected\n do\n if [ \"$ref\" = \"$protected\" ] && [ \"$old\" != \"$expected\" ]; then\n current=$(git rev-parse --verify --quiet \"$ref\") || current={}\n if [ \"$current\" != \"$expected\" ]; then\n printf '%s\\n' \"bitbygit recovery blocked: $ref changed after final validation\" >&2\n exit 1\n fi\n fi\n done < {}\ndone < \"$input\"\n", + shell_quote(&transaction_input.to_string_lossy()), + user_hook_command, + ZERO_OID, + shell_quote(&expected_path.to_string_lossy()) + ); + let transaction_hook = hooks.path.join("reference-transaction"); + fs::write(&transaction_hook, script) + .map_err(|source| recovery_hook_io_error("write", source))?; + make_recovery_hook_executable(&transaction_hook)?; + Ok(hooks) + } +} + +impl Drop for RecoveryHooks { + fn drop(&mut self) { + let _result = fs::remove_dir_all(&self.path); + } +} + +fn recovery_ref_values(state: &RecoveryState) -> Result, GitError> { + let mut refs = BTreeMap::new(); + if let (Some(reference), Some(oid)) = (&state.head.reference, &state.head.oid) { + refs.insert(reference.clone(), oid.clone()); + } + for line in state.refs.split(|byte| *byte == b'\n') { + if line.is_empty() { + continue; + } + let fields = line.split(|byte| *byte == 0).collect::>(); + if fields.len() != 3 { + return Err(parse_error("recovery ref output has unexpected fields")); + } + let reference = parse_utf8(fields[0], "recovery ref name")?; + let oid = parse_utf8(fields[1], "recovery ref oid")?; + refs.insert(reference.to_owned(), oid.to_owned()); + } + Ok(refs) +} + +fn recovery_hook_io_error(action: &str, source: std::io::Error) -> GitError { + GitError::Blocked { + message: format!("recovery is blocked because its ref fence could not {action}: {source}"), + } +} + +#[cfg(unix)] +fn link_recovery_hook(source: &Path, destination: &Path) -> Result<(), GitError> { + std::os::unix::fs::symlink(source, destination) + .map_err(|error| recovery_hook_io_error("preserve configured hooks", error)) +} + +#[cfg(not(unix))] +fn link_recovery_hook(source: &Path, destination: &Path) -> Result<(), GitError> { + if source.is_file() { + fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| recovery_hook_io_error("preserve configured hooks", error)) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn make_recovery_hook_executable(path: &Path) -> Result<(), GitError> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .map_err(|error| recovery_hook_io_error("read its ref fence", error))? + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(path, permissions) + .map_err(|error| recovery_hook_io_error("enable its ref fence", error)) +} + +#[cfg(not(unix))] +fn make_recovery_hook_executable(_path: &Path) -> Result<(), GitError> { + Ok(()) +} + #[derive(Default)] struct RecoveryCapture { output_bytes: usize, @@ -3330,6 +3513,56 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_executes_every_supported_action() -> Result<(), Box> { + let (merge_continue_repo, _original_head) = prepare_merge_conflict()?; + merge_continue_repo.write("conflict.txt", "resolved\n")?; + merge_continue_repo.run(["add", "conflict.txt"])?; + let git = Git::new(merge_continue_repo.path()); + let state = git.recovery_state()?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; + assert_eq!(git.status()?.operation, None); + + let (merge_abort_repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(merge_abort_repo.path()); + let state = git.recovery_state()?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &state)?; + assert_eq!(git.status()?.operation, None); + assert_eq!( + merge_abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + + let (rebase_continue_repo, _original_head) = prepare_rebase_conflict()?; + rebase_continue_repo.write("conflict.txt", "resolved\n")?; + rebase_continue_repo.run(["add", "conflict.txt"])?; + let git = Git::new(rebase_continue_repo.path()); + let state = git.recovery_state()?; + git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &state, + )?; + assert_eq!(git.status()?.operation, None); + + let (rebase_abort_repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(rebase_abort_repo.path()); + let state = git.recovery_state()?; + git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Abort, &state)?; + assert_eq!(git.status()?.operation, None); + assert_eq!( + rebase_abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + + let (rebase_skip_repo, _original_head) = prepare_rebase_conflict()?; + let git = Git::new(rebase_skip_repo.path()); + let state = git.recovery_state()?; + git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &state)?; + assert_eq!(git.status()?.operation, None); + Ok(()) + } + #[test] fn exact_recovery_rejects_state_changed_after_preview() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -3581,8 +3814,8 @@ mod tests { } #[test] - fn exact_recovery_fails_closed_without_touching_unrelated_untracked_tree() - -> Result<(), Box> { + fn exact_recovery_ignores_and_preserves_unrelated_untracked_tree() -> Result<(), Box> + { let (repo, _original_head) = prepare_merge_conflict()?; fs::create_dir(repo.path().join("ignored"))?; fs::write(repo.path().join(".git/info/exclude"), "ignored/\n")?; @@ -3594,18 +3827,9 @@ mod tests { let expected = git.recovery_state()?; repo.write("notes.txt", "changed after preview\n")?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected exact recovery to fail closed".into()); - }; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert!( - error - .to_string() - .contains("cannot atomically fence recovery") - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(git.status()?.operation, None); assert_eq!( fs::read_to_string(repo.path().join("notes.txt"))?, "changed after preview\n" @@ -3768,13 +3992,15 @@ mod tests { } #[test] - fn exact_rebase_abort_fails_closed_on_branch_move_after_final_validation() + fn exact_rebase_abort_preserves_branch_move_after_final_validation() -> Result<(), Box> { use std::sync::mpsc; let (repo, original_head) = prepare_rebase_conflict()?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?; + let preview_conflict = fs::read(repo.path().join("conflict.txt"))?; let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); assert_ne!(newer_head, original_head); @@ -3811,11 +4037,15 @@ mod tests { assert!(output.status.success()); assert!( - error - .to_string() - .contains("cannot atomically fence recovery") + error.to_string().contains("changed after final validation"), + "{error}" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(repo.git_stdout(["rev-parse", "HEAD"])?, preview_head); + assert_eq!( + fs::read(repo.path().join("conflict.txt"))?, + preview_conflict + ); assert_eq!( repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), newer_head @@ -3877,7 +4107,10 @@ mod tests { repo.run(["add", "conflict.txt"])?; let git = Git::new(repo.path()); - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + let state = git.recovery_state()?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state) + else { return Err("expected configured signing failure".into()); }; let GitError::GitFailed { @@ -3889,7 +4122,7 @@ mod tests { else { return Err("expected standard git merge failure".into()); }; - assert_eq!(args, ["merge".to_owned(), "--continue".to_owned()]); + assert!(args.ends_with(&["merge".to_owned(), "--continue".to_owned()])); assert!(format!("{stdout}{stderr}").contains('\u{fffd}')); assert!(repo.path().join("hook-ran").exists()); assert!(repo.path().join("signing-ran").exists()); diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index e5722b1..e005619 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -5062,8 +5062,8 @@ mod tests { } #[test] - fn confirmed_recovery_fails_closed_and_records_stable_audit_entries() - -> Result<(), Box> { + fn confirmed_recovery_succeeds_and_records_stable_audit_entries() -> Result<(), Box> + { let repo = merge_conflict_repo("recovery-audit")?; let operation = OperationPlanner::new(&repo) .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) @@ -5073,21 +5073,49 @@ mod tests { let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) .execute(&operation.plan, operation.context); - assert!(!execution.succeeded()); - assert!( - execution - .message() - .contains("cannot atomically fence recovery") - ); - assert_eq!( - Git::new(&repo).status()?.operation, - Some(RepositoryOperation::Merge) - ); + assert!(execution.succeeded(), "{}", execution.message()); + assert_eq!(Git::new(&repo).status()?.operation, None); let entries = LocalStore::open(paths)?.list_audit_entries()?; assert_eq!(entries.len(), 2); assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); assert_eq!(entries[0].message, "pending"); - assert_eq!(entries[1].result, "error"); + assert_eq!(entries[1].result, "ok"); + Ok(()) + } + + #[test] + fn confirmed_recovery_executes_all_supported_requests() -> Result<(), Box> { + let merge_continue = merge_conflict_repo("recovery-merge-continue")?; + std::fs::write(merge_continue.join("conflict.txt"), "resolved\n")?; + git_stdout(&merge_continue, &["add", "conflict.txt"])?; + let merge_abort = merge_conflict_repo("recovery-merge-abort")?; + + let rebase_continue = rebase_conflict_repo("recovery-rebase-continue")?; + std::fs::write(rebase_continue.join("conflict.txt"), "resolved\n")?; + git_stdout(&rebase_continue, &["add", "conflict.txt"])?; + let rebase_abort = rebase_conflict_repo("recovery-rebase-abort")?; + let rebase_skip = rebase_conflict_repo("recovery-rebase-skip")?; + + for (repo, request) in [ + (merge_continue, RecoveryRequest::MergeContinue), + (merge_abort, RecoveryRequest::MergeAbort), + (rebase_continue, RecoveryRequest::RebaseContinue), + (rebase_abort, RecoveryRequest::RebaseAbort), + (rebase_skip, RecoveryRequest::RebaseSkip), + ] { + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(request)) + .map_err(std::io::Error::other)?; + let paths = isolated_store_paths(&format!("recovery-{request:?}"))?; + let execution = PlanExecutor::with_audit_paths(&repo, paths) + .execute(&operation.plan, operation.context); + assert!( + execution.succeeded(), + "{request:?}: {}", + execution.message() + ); + assert_eq!(Git::new(repo).status()?.operation, None); + } Ok(()) } @@ -6776,6 +6804,36 @@ mod tests { Ok(repo) } + fn rebase_conflict_repo(name: &str) -> Result> { + let repo = isolated_git_repo(name)?; + configure_git_identity(&repo)?; + std::fs::write(repo.join("conflict.txt"), "base\n")?; + git_stdout(&repo, &["add", "conflict.txt"])?; + git_stdout(&repo, &["commit", "-m", "base"])?; + let base = git_stdout(&repo, &["branch", "--show-current"])?; + git_stdout(&repo, &["checkout", "-b", "topic"])?; + std::fs::write(repo.join("conflict.txt"), "topic\n")?; + git_stdout(&repo, &["commit", "-am", "topic"])?; + git_stdout(&repo, &["checkout", base.trim()])?; + std::fs::write(repo.join("conflict.txt"), "upstream\n")?; + git_stdout(&repo, &["commit", "-am", "upstream"])?; + git_stdout(&repo, &["checkout", "topic"])?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rebase", base.trim()]) + .output()?; + if output.status.success() { + return Err(std::io::Error::other("expected rebase conflict").into()); + } + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Rebase) + ); + Ok(repo) + } + fn isolated_bare_git_repo(name: &str) -> Result> { let root = isolated_temp_root(name)?; let output = std::process::Command::new("git") From e459c8c5b20373d2b9fa5386b5cf74a0ca5a5364 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 17:49:08 -0400 Subject: [PATCH 11/53] fix: fence recovery at execution boundary --- crates/bitbygit-git/src/lib.rs | 369 ++++++++++++++++----------------- 1 file changed, 173 insertions(+), 196 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 839fa57..80023b9 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -8,7 +8,6 @@ use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; -use std::sync::atomic::{AtomicU64, Ordering}; use sha2::{Digest, Sha256}; @@ -43,7 +42,6 @@ const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; const MAX_RECOVERY_SUBPROCESSES: usize = 12; -static NEXT_RECOVERY_HOOK_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone)] pub struct Git { @@ -299,35 +297,21 @@ impl Git { ), }); } - let final_state = self.recovery_state()?; - if final_state != current_state { - return Err(GitError::Blocked { - message: format!( - "{} {} is blocked because repository state changed during final recovery validation", - operation.label(), - action.label() - ), - }); - } run_recovery_execution_hook(&self.cwd); let execution_state = self.recovery_state()?; - if execution_state != final_state { + if execution_state != current_state { return Err(GitError::Blocked { message: format!( - "{} {} is blocked because repository state changed after final validation", + "{} {} is blocked because repository state changed at the recovery execution boundary", operation.label(), action.label() ), }); } - let hooks = RecoveryHooks::install(self, &execution_state)?; - self.run_recovery_args_with_hooks( - vec![ - operation.label().to_owned(), - format!("--{}", action.label()), - ], - &hooks.path, - ) + self.run_recovery_args(vec![ + operation.label().to_owned(), + format!("--{}", action.label()), + ]) } pub fn push_current_branch( @@ -1270,44 +1254,10 @@ impl Git { self.run_args_with_editor(args, false) } - #[cfg(test)] fn run_recovery_args(&self, args: Vec) -> Result { self.run_args_with_editor(args, true) } - fn run_recovery_args_with_hooks( - &self, - mut args: Vec, - hooks_path: &Path, - ) -> Result { - let display_args = args.clone(); - let hooks_path = hooks_path.to_str().ok_or_else(|| GitError::Blocked { - message: "recovery is blocked because its ref fence path is not valid UTF-8".to_owned(), - })?; - args.splice( - 0..0, - ["-c".to_owned(), format!("core.hooksPath={hooks_path}")], - ); - match self.run_args_with_editor(args, true) { - Err(GitError::GitFailed { - status, - stdout, - stderr, - .. - }) => Err(GitError::GitFailed { - args: display_args, - status, - stdout, - stderr, - }), - Err(GitError::Io { source, .. }) => Err(GitError::Io { - args: display_args, - source, - }), - result => result, - } - } - fn run_args_with_editor( &self, args: Vec, @@ -1516,143 +1466,6 @@ impl Git { } } -struct RecoveryHooks { - path: PathBuf, -} - -impl RecoveryHooks { - fn install(git: &Git, state: &RecoveryState) -> Result { - let git_dir = git.git_path("")?; - let id = NEXT_RECOVERY_HOOK_ID.fetch_add(1, Ordering::Relaxed); - let path = git_dir.join(format!( - "bitbygit-recovery-hooks-{}-{id}", - std::process::id() - )); - fs::create_dir(&path).map_err(|source| recovery_hook_io_error("create", source))?; - let hooks = Self { path }; - - let configured_hook = git.hook_path("reference-transaction")?; - if let Some(configured_dir) = configured_hook.parent() { - match fs::read_dir(configured_dir) { - Ok(entries) => { - for entry in entries { - let entry = - entry.map_err(|source| recovery_hook_io_error("read", source))?; - if entry.file_name() == "reference-transaction" { - continue; - } - link_recovery_hook(&entry.path(), &hooks.path.join(entry.file_name()))?; - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(recovery_hook_io_error("read", source)), - } - } - - let expected_path = hooks.path.join("expected-refs"); - let mut expected = Vec::new(); - for (reference, oid) in recovery_ref_values(state)? { - expected.extend_from_slice(oid.as_bytes()); - expected.push(b'\t'); - expected.extend_from_slice(reference.as_bytes()); - expected.push(b'\n'); - } - fs::write(&expected_path, expected) - .map_err(|source| recovery_hook_io_error("write", source))?; - - let transaction_input = hooks.path.join("transaction-input"); - let user_hook = hook_is_enabled(&configured_hook).then_some(configured_hook); - let user_hook_command = user_hook - .as_ref() - .map(|hook| { - format!( - "{} \"$1\" < {} || exit $?\n", - shell_quote(&hook.to_string_lossy()), - shell_quote(&transaction_input.to_string_lossy()) - ) - }) - .unwrap_or_default(); - let script = format!( - "#!/bin/sh\ninput={}\ncat > \"$input\" || exit 1\n{}[ \"$1\" = prepared ] || exit 0\nwhile read -r old new ref\ndo\n while IFS='\t' read -r expected protected\n do\n if [ \"$ref\" = \"$protected\" ] && [ \"$old\" != \"$expected\" ]; then\n current=$(git rev-parse --verify --quiet \"$ref\") || current={}\n if [ \"$current\" != \"$expected\" ]; then\n printf '%s\\n' \"bitbygit recovery blocked: $ref changed after final validation\" >&2\n exit 1\n fi\n fi\n done < {}\ndone < \"$input\"\n", - shell_quote(&transaction_input.to_string_lossy()), - user_hook_command, - ZERO_OID, - shell_quote(&expected_path.to_string_lossy()) - ); - let transaction_hook = hooks.path.join("reference-transaction"); - fs::write(&transaction_hook, script) - .map_err(|source| recovery_hook_io_error("write", source))?; - make_recovery_hook_executable(&transaction_hook)?; - Ok(hooks) - } -} - -impl Drop for RecoveryHooks { - fn drop(&mut self) { - let _result = fs::remove_dir_all(&self.path); - } -} - -fn recovery_ref_values(state: &RecoveryState) -> Result, GitError> { - let mut refs = BTreeMap::new(); - if let (Some(reference), Some(oid)) = (&state.head.reference, &state.head.oid) { - refs.insert(reference.clone(), oid.clone()); - } - for line in state.refs.split(|byte| *byte == b'\n') { - if line.is_empty() { - continue; - } - let fields = line.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 { - return Err(parse_error("recovery ref output has unexpected fields")); - } - let reference = parse_utf8(fields[0], "recovery ref name")?; - let oid = parse_utf8(fields[1], "recovery ref oid")?; - refs.insert(reference.to_owned(), oid.to_owned()); - } - Ok(refs) -} - -fn recovery_hook_io_error(action: &str, source: std::io::Error) -> GitError { - GitError::Blocked { - message: format!("recovery is blocked because its ref fence could not {action}: {source}"), - } -} - -#[cfg(unix)] -fn link_recovery_hook(source: &Path, destination: &Path) -> Result<(), GitError> { - std::os::unix::fs::symlink(source, destination) - .map_err(|error| recovery_hook_io_error("preserve configured hooks", error)) -} - -#[cfg(not(unix))] -fn link_recovery_hook(source: &Path, destination: &Path) -> Result<(), GitError> { - if source.is_file() { - fs::copy(source, destination) - .map(|_| ()) - .map_err(|error| recovery_hook_io_error("preserve configured hooks", error)) - } else { - Ok(()) - } -} - -#[cfg(unix)] -fn make_recovery_hook_executable(path: &Path) -> Result<(), GitError> { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = fs::metadata(path) - .map_err(|error| recovery_hook_io_error("read its ref fence", error))? - .permissions(); - permissions.set_mode(0o700); - fs::set_permissions(path, permissions) - .map_err(|error| recovery_hook_io_error("enable its ref fence", error)) -} - -#[cfg(not(unix))] -fn make_recovery_hook_executable(_path: &Path) -> Result<(), GitError> { - Ok(()) -} - #[derive(Default)] struct RecoveryCapture { output_bytes: usize, @@ -3982,7 +3795,7 @@ mod tests { assert!( error .to_string() - .contains("during final recovery validation") + .contains("at the recovery execution boundary") ); assert_eq!( fs::read_to_string(repo.path().join("conflict.txt"))?, @@ -3992,7 +3805,7 @@ mod tests { } #[test] - fn exact_rebase_abort_preserves_branch_move_after_final_validation() + fn exact_rebase_abort_blocks_ref_race_at_execution_boundary_without_partial_recovery() -> Result<(), Box> { use std::sync::mpsc; @@ -4037,11 +3850,17 @@ mod tests { assert!(output.status.success()); assert!( - error.to_string().contains("changed after final validation"), + error + .to_string() + .contains("at the recovery execution boundary"), "{error}" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); assert_eq!(repo.git_stdout(["rev-parse", "HEAD"])?, preview_head); + let current = git.recovery_state()?; + assert_eq!(current.index, expected.index); + assert_eq!(current.worktree, expected.worktree); + assert_eq!(current.metadata, expected.metadata); assert_eq!( fs::read(repo.path().join("conflict.txt"))?, preview_conflict @@ -4053,6 +3872,164 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_blocks_worktree_race_at_execution_boundary() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let conflict = repo.path().join("conflict.txt"); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write(conflict, "late worktree edit\n"); + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((repo.path(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected late worktree edit to block recovery".into()); + }; + + assert!( + error + .to_string() + .contains("at the recovery execution boundary") + ); + let current = git.recovery_state()?; + assert_eq!(current.head, expected.head); + assert_eq!(current.index, expected.index); + assert_eq!(current.metadata, expected.metadata); + assert_eq!(current.refs, expected.refs); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "late worktree edit\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[test] + fn exact_recovery_blocks_index_race_at_execution_boundary() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let original_blob = repo + .git_stdout(["rev-parse", "HEAD:conflict.txt"])? + .trim() + .to_owned(); + let repo_path = repo.path(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = Command::new("git") + .current_dir(repo_path) + .args([ + "update-index", + "--cacheinfo", + "100644", + &original_blob, + "conflict.txt", + ]) + .output(); + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((repo.path(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected late index edit to block recovery".into()); + }; + + assert!( + error + .to_string() + .contains("at the recovery execution boundary") + ); + let current = git.recovery_state()?; + assert_eq!(current.head, expected.head); + assert_ne!(current.index, expected.index); + assert_eq!(current.worktree, expected.worktree); + assert_eq!(current.metadata, expected.metadata); + assert_eq!(current.refs, expected.refs); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_leaves_configured_hook_namespace_and_config_unchanged() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let hooks = repo.path().join("hooks"); + fs::create_dir(&hooks)?; + let support_files = [ + ("expected-refs", b"configured expected refs\n".as_slice()), + ( + "transaction-input", + b"configured transaction input\n".as_slice(), + ), + ]; + for (name, contents) in support_files { + fs::write(hooks.join(name), contents)?; + } + let commit_hook = hooks.join("commit-msg"); + fs::write( + &commit_hook, + "#!/bin/sh\ntouch configured-commit-hook-ran\n", + )?; + let transaction_hook = hooks.join("reference-transaction"); + fs::write( + &transaction_hook, + "#!/bin/sh\nprintf '%s\\n' \"$1\" >> configured-reference-hook-ran\n", + )?; + for hook in [&commit_hook, &transaction_hook] { + let mut permissions = fs::metadata(hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(hook, permissions)?; + } + repo.run(["config", "core.hooksPath", "hooks"])?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let state = git.recovery_state()?; + let config_path = git.git_path("config")?; + let config_before = fs::read(&config_path)?; + let hook_files = [ + hooks.join("expected-refs"), + hooks.join("transaction-input"), + commit_hook, + transaction_hook, + ]; + let contents_before = hook_files + .iter() + .map(fs::read) + .collect::, _>>()?; + + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; + + assert_eq!(git.status()?.operation, None); + assert!(repo.path().join("configured-commit-hook-ran").exists()); + assert!(repo.path().join("configured-reference-hook-ran").exists()); + assert_eq!(fs::read(config_path)?, config_before); + for (path, contents) in hook_files.iter().zip(contents_before) { + assert_eq!(fs::read(path)?, contents); + } + assert!(fs::read_dir(git.git_path("")?)?.all(|entry| { + entry.is_ok_and(|entry| { + !entry + .file_name() + .to_string_lossy() + .starts_with("bitbygit-recovery-hooks-") + }) + })); + Ok(()) + } + #[test] fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_merge_conflict()?; From aa62cbd8e7c3113bc8145708fc0a0e0bd0a6b9dd Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 18:06:54 -0400 Subject: [PATCH 12/53] fix: isolate recovery before atomic promotion --- Cargo.lock | 1 + crates/bitbygit-git/Cargo.toml | 3 + crates/bitbygit-git/src/lib.rs | 652 +++++++++++++++++++++++++++------ 3 files changed, 544 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bb121b..27bc333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ name = "bitbygit-git" version = "0.1.0" dependencies = [ "libc", + "rustix", "sha2", ] diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 5993160..9e7951a 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -14,3 +14,6 @@ sha2 = "0.10" [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { version = "0.38", features = ["fs"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 80023b9..0818d65 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -42,6 +42,9 @@ const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; const MAX_RECOVERY_SUBPROCESSES: usize = 12; +const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; +const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; +const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; #[derive(Debug, Clone)] pub struct Git { @@ -297,21 +300,16 @@ impl Git { ), }); } - run_recovery_execution_hook(&self.cwd); - let execution_state = self.recovery_state()?; - if execution_state != current_state { - return Err(GitError::Blocked { - message: format!( - "{} {} is blocked because repository state changed at the recovery execution boundary", - operation.label(), - action.label() - ), - }); - } - self.run_recovery_args(vec![ - operation.label().to_owned(), - format!("--{}", action.label()), - ]) + let mut transaction = RecoveryTransaction::prepare(self)?; + let output = transaction.run_recovery( + self, + vec![ + operation.label().to_owned(), + format!("--{}", action.label()), + ], + )?; + transaction.promote(self, ¤t_state)?; + Ok(output) } pub fn push_current_branch( @@ -1254,6 +1252,7 @@ impl Git { self.run_args_with_editor(args, false) } + #[cfg(test)] fn run_recovery_args(&self, args: Vec) -> Result { self.run_args_with_editor(args, true) } @@ -1466,6 +1465,361 @@ impl Git { } } +struct RecoveryTransaction { + root: PathBuf, + candidate: PathBuf, + baseline: RecoveryGeneration, + keep_candidate: bool, +} + +impl RecoveryTransaction { + fn prepare(git: &Git) -> Result { + let root = git + .repo_root()? + .canonicalize() + .map_err(|source| recovery_transaction_io("resolve repository root", source))?; + let git_dir = git + .git_path("")? + .canonicalize() + .map_err(|source| recovery_transaction_io("resolve Git directory", source))?; + let embedded_git_dir = root.join(".git"); + if !embedded_git_dir.is_dir() + || embedded_git_dir.canonicalize().map_err(|source| { + recovery_transaction_io("resolve embedded Git directory", source) + })? != git_dir + { + return Err(recovery_transaction_blocked( + "atomic recovery requires a standalone repository with an embedded .git directory", + )); + } + let common_dir = path_from_bytes(strip_byte_line_ending( + &git.run_raw(["rev-parse", "--path-format=absolute", "--git-common-dir"])? + .stdout, + )) + .canonicalize() + .map_err(|source| recovery_transaction_io("resolve common Git directory", source))?; + if common_dir != git_dir { + return Err(recovery_transaction_blocked( + "atomic recovery does not support a shared common Git directory", + )); + } + + let baseline = RecoveryGeneration::capture(&root)?; + let parent = root.parent().ok_or_else(|| { + recovery_transaction_blocked("repository root has no parent for isolated recovery") + })?; + let candidate = create_recovery_candidate(parent)?; + let mut transaction = Self { + root, + candidate, + baseline, + keep_candidate: false, + }; + transaction.copy_repository()?; + + let live_after_copy = RecoveryGeneration::capture(&transaction.root)?; + let copied = RecoveryGeneration::capture(&transaction.candidate)?; + if live_after_copy != transaction.baseline || copied != transaction.baseline { + return Err(recovery_transaction_blocked( + "repository changed while isolated recovery state was prepared", + )); + } + Ok(transaction) + } + + fn copy_repository(&mut self) -> Result<(), GitError> { + let output = Command::new("cp") + .args(["-a", "--reflink=auto", "--"]) + .arg(self.root.join(".")) + .arg(&self.candidate) + .output() + .map_err(|source| recovery_transaction_io("start isolated repository copy", source))?; + if !output.status.success() { + return Err(recovery_transaction_blocked(format!( + "isolated repository copy failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + fs::set_permissions( + &self.candidate, + fs::metadata(&self.root) + .map_err(|source| { + recovery_transaction_io("read repository root permissions", source) + })? + .permissions(), + ) + .map_err(|source| { + recovery_transaction_io("preserve repository root permissions", source) + })?; + Ok(()) + } + + fn run_recovery(&self, git: &Git, args: Vec) -> Result { + let mut command = Command::new("unshare"); + command + .args([ + "--user", + "--map-root-user", + "--mount", + "--fork", + "sh", + "-c", + "mount --bind \"$1\" \"$2\" && cd \"$2\" && shift 2 && exec git \"$@\"", + "bitbygit-recovery", + ]) + .arg(&self.candidate) + .arg(&self.root) + .args(&args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never") + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true"); + if git.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { + let ssh_executable = git + .ssh_executable + .as_ref() + .map(|path| shell_quote(&path.to_string_lossy())) + .unwrap_or_else(|| "ssh".to_owned()); + command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); + } + let output = command.output().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !output.status.success() { + return Err(GitError::GitFailed { + args, + status: output.status, + stdout, + stderr, + }); + } + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn promote(&mut self, live_git: &Git, expected_state: &RecoveryState) -> Result<(), GitError> { + if live_git.recovery_state()? != *expected_state + || RecoveryGeneration::capture(&self.root)? != self.baseline + { + return Err(recovery_transaction_blocked( + "repository changed while recovery executed in isolation", + )); + } + + run_recovery_promotion_hook(&self.root); + atomic_exchange_directories(&self.root, &self.candidate)?; + self.keep_candidate = true; + if RecoveryGeneration::capture(&self.candidate)? != self.baseline { + if let Err(error) = atomic_exchange_directories(&self.root, &self.candidate) { + return Err(recovery_transaction_blocked(format!( + "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" + ))); + } + self.keep_candidate = false; + return Err(recovery_transaction_blocked( + "repository changed during atomic recovery promotion", + )); + } + Ok(()) + } +} + +impl Drop for RecoveryTransaction { + fn drop(&mut self) { + if !self.keep_candidate { + let _result = fs::remove_dir_all(&self.candidate); + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct RecoveryGeneration { + entries: Vec, +} + +impl RecoveryGeneration { + fn capture(root: &Path) -> Result { + let mut entries = Vec::new(); + let mut pending = vec![PathBuf::new()]; + let mut path_bytes = 0_usize; + let mut file_bytes = 0_u64; + while let Some(relative_dir) = pending.pop() { + let directory = root.join(&relative_dir); + let mut children = fs::read_dir(&directory) + .map_err(|source| recovery_transaction_io("read repository generation", source))? + .collect::, _>>() + .map_err(|source| recovery_transaction_io("read repository generation", source))?; + children.sort_by_key(|entry| entry.file_name()); + for child in children.into_iter().rev() { + let relative = relative_dir.join(child.file_name()); + path_bytes = + path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); + if entries.len() == MAX_RECOVERY_GENERATION_ENTRIES + || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_transaction_blocked( + "repository generation exceeds atomic recovery entry or path bounds", + )); + } + let path = child.path(); + let metadata = fs::symlink_metadata(&path).map_err(|source| { + recovery_transaction_io("inspect repository generation entry", source) + })?; + let file_type = metadata.file_type(); + let value = if file_type.is_dir() { + pending.push(relative.clone()); + RecoveryGenerationValue::Directory { + mode: recovery_file_mode(&metadata), + } + } else if file_type.is_file() { + file_bytes = file_bytes.saturating_add(metadata.len()); + if file_bytes > MAX_RECOVERY_GENERATION_FILE_BYTES { + return Err(recovery_transaction_blocked( + "repository generation exceeds atomic recovery content bound", + )); + } + let identity = recovery_file_identity(&metadata); + let mut file = BufReader::new(fs::File::open(&path).map_err(|source| { + recovery_transaction_io("open repository generation file", source) + })?); + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|source| { + recovery_transaction_io("hash repository generation file", source) + })?; + let current = fs::symlink_metadata(&path).map_err(|source| { + recovery_transaction_io("recheck repository generation file", source) + })?; + if !current.is_file() + || current.len() != metadata.len() + || recovery_file_mode(¤t) != recovery_file_mode(&metadata) + || recovery_file_identity(¤t) != identity + { + return Err(recovery_transaction_blocked( + "repository generation changed while it was captured", + )); + } + RecoveryGenerationValue::File { + mode: recovery_file_mode(&metadata), + size: metadata.len(), + digest: hasher.finalize().into(), + } + } else if file_type.is_symlink() { + RecoveryGenerationValue::Symlink(fs::read_link(&path).map_err(|source| { + recovery_transaction_io("read repository generation symlink", source) + })?) + } else { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support special filesystem entry {}", + relative.display() + ))); + }; + entries.push(RecoveryGenerationEntry { + path: relative, + value, + }); + } + } + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(Self { entries }) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct RecoveryGenerationEntry { + path: PathBuf, + value: RecoveryGenerationValue, +} + +#[derive(Debug, PartialEq, Eq)] +enum RecoveryGenerationValue { + Directory { + mode: u32, + }, + File { + mode: u32, + size: u64, + digest: [u8; 32], + }, + Symlink(PathBuf), +} + +fn create_recovery_candidate(parent: &Path) -> Result { + for attempt in 0..100_u32 { + let candidate = parent.join(format!( + ".bitbygit-recovery-backup-{}-{}-{attempt}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(source) => { + return Err(recovery_transaction_io( + "create isolated repository", + source, + )); + } + } + } + Err(recovery_transaction_blocked( + "could not reserve an isolated repository path", + )) +} + +#[cfg(target_os = "linux")] +fn atomic_exchange_directories(left: &Path, right: &Path) -> Result<(), GitError> { + use rustix::fs::{CWD, RenameFlags, renameat_with}; + + renameat_with(CWD, left, CWD, right, RenameFlags::EXCHANGE).map_err(|source| { + recovery_transaction_blocked(format!("atomic directory exchange failed: {source}")) + }) +} + +#[cfg(not(target_os = "linux"))] +fn atomic_exchange_directories(_left: &Path, _right: &Path) -> Result<(), GitError> { + Err(recovery_transaction_blocked( + "atomic recovery is not supported on this platform", + )) +} + +fn recovery_transaction_io(action: &str, source: std::io::Error) -> GitError { + recovery_transaction_blocked(format!("atomic recovery could not {action}: {source}")) +} + +fn recovery_transaction_blocked(message: impl Into) -> GitError { + GitError::Blocked { + message: message.into(), + } +} + +#[cfg(test)] +static RECOVERY_PROMOTION_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_promotion_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_PROMOTION_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_promotion_hook(_root: &Path) {} + #[derive(Default)] struct RecoveryCapture { output_bytes: usize, @@ -1830,23 +2184,6 @@ fn run_recovery_capture_hook(cwd: &Path) { #[cfg(not(test))] fn run_recovery_capture_hook(_cwd: &Path) {} -#[cfg(test)] -static RECOVERY_EXECUTION_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_execution_hook(cwd: &Path) { - if let Ok(mut hooks) = RECOVERY_EXECUTION_HOOKS.lock() { - if let Some(index) = hooks.iter().position(|(target, _)| target == cwd) { - let (_, hook) = hooks.swap_remove(index); - hook(); - } - } -} - -#[cfg(not(test))] -fn run_recovery_execution_hook(_cwd: &Path) {} - #[cfg(test)] static RECOVERY_FILE_OPEN_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3792,11 +4129,7 @@ mod tests { return Err("expected concurrent edit to block recovery".into()); }; editor.join().map_err(|_| "editor thread panicked")?; - assert!( - error - .to_string() - .contains("at the recovery execution boundary") - ); + assert!(error.to_string().contains("repository changed")); assert_eq!( fs::read_to_string(repo.path().join("conflict.txt"))?, "concurrent edit\n" @@ -3804,55 +4137,38 @@ mod tests { Ok(()) } + #[cfg(unix)] #[test] - fn exact_rebase_abort_blocks_ref_race_at_execution_boundary_without_partial_recovery() + fn isolated_rebase_abort_preserves_ref_race_during_prepared_hook_without_partial_recovery() -> Result<(), Box> { - use std::sync::mpsc; - let (repo, original_head) = prepare_rebase_conflict()?; + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?; let preview_conflict = fs::read(repo.path().join("conflict.txt"))?; let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); assert_ne!(newer_head, original_head); - - let (validated_tx, validated_rx) = mpsc::channel(); - let (moved_tx, moved_rx) = mpsc::channel(); - let repo_path = repo.path(); - let moved_head = newer_head.clone(); - let mover = std::thread::spawn(move || { - let _ = validated_rx.recv(); - let output = Command::new("git") - .current_dir(repo_path) - .args(["update-ref", "refs/heads/topic", &moved_head]) - .output(); - let _ = moved_tx.send(()); - output + let worker_path = repo.path(); + let worker_state = expected.clone(); + let worker = std::thread::spawn(move || { + Git::new(worker_path).recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &worker_state, + ) }); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = validated_tx.send(()); - let _ = moved_rx.recv(); - }); - RECOVERY_EXECUTION_HOOKS - .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((repo.path(), hook)); + wait_for_recovery_barrier(&signal)?; + fs::write(git.git_path("refs/heads/topic")?, format!("{newer_head}\n"))?; + fs::write(&release, [])?; - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { + let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { return Err("expected exact rebase abort to fail closed".into()); }; - let output = mover.join().map_err(|_| "branch mover thread panicked")??; - - assert!(output.status.success()); assert!( error .to_string() - .contains("at the recovery execution boundary"), + .contains("while recovery executed in isolation"), "{error}" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); @@ -3869,33 +4185,42 @@ mod tests { repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), newer_head ); + let _ = fs::remove_file(signal); + let _ = fs::remove_file(release); Ok(()) } + #[cfg(unix)] #[test] - fn exact_recovery_blocks_worktree_race_at_execution_boundary() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; + fn isolated_recovery_preserves_post_spawn_worktree_race() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; - let conflict = repo.path().join("conflict.txt"); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write(conflict, "late worktree edit\n"); + let worker_path = repo.path(); + let worker_state = expected.clone(); + let worker = std::thread::spawn(move || { + Git::new(worker_path).recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &worker_state, + ) }); - RECOVERY_EXECUTION_HOOKS - .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((repo.path(), hook)); + wait_for_recovery_barrier(&signal)?; + fs::write( + repo.path().join("conflict.txt"), + "post-spawn worktree edit\n", + )?; + fs::write(&release, [])?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { + let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { return Err("expected late worktree edit to block recovery".into()); }; assert!( error .to_string() - .contains("at the recovery execution boundary") + .contains("while recovery executed in isolation") ); let current = git.recovery_state()?; assert_eq!(current.head, expected.head); @@ -3904,57 +4229,104 @@ mod tests { assert_eq!(current.refs, expected.refs); assert_eq!( fs::read_to_string(repo.path().join("conflict.txt"))?, - "late worktree edit\n" + "post-spawn worktree edit\n" ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + let _ = fs::remove_file(signal); + let _ = fs::remove_file(release); Ok(()) } + #[cfg(unix)] #[test] - fn exact_recovery_blocks_index_race_at_execution_boundary() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; + fn isolated_recovery_preserves_post_spawn_index_race() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; let original_blob = repo .git_stdout(["rev-parse", "HEAD:conflict.txt"])? .trim() .to_owned(); - let repo_path = repo.path(); + let worker_path = repo.path(); + let worker_state = expected.clone(); + let worker = std::thread::spawn(move || { + Git::new(worker_path).recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &worker_state, + ) + }); + wait_for_recovery_barrier(&signal)?; + let output = Command::new("git") + .current_dir(repo.path()) + .args([ + "update-index", + "--cacheinfo", + "100644", + &original_blob, + "conflict.txt", + ]) + .output()?; + assert!(output.status.success()); + fs::write(&release, [])?; + + let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { + return Err("expected late index edit to block recovery".into()); + }; + + assert!( + error + .to_string() + .contains("while recovery executed in isolation") + ); + let current = git.recovery_state()?; + assert_eq!(current.head, expected.head); + assert_ne!(current.index, expected.index); + assert_eq!(current.worktree, expected.worktree); + assert_eq!(current.metadata, expected.metadata); + assert_eq!(current.refs, expected.refs); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + let _ = fs::remove_file(signal); + let _ = fs::remove_file(release); + Ok(()) + } + + #[test] + fn atomic_promotion_rolls_back_race_after_expected_value_check() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let conflict = repo.path().join("conflict.txt"); let hook: RecoveryCaptureHook = Box::new(move || { - let _ = Command::new("git") - .current_dir(repo_path) - .args([ - "update-index", - "--cacheinfo", - "100644", - &original_blob, - "conflict.txt", - ]) - .output(); + let _ = fs::write(conflict, "promotion race\n"); }); - RECOVERY_EXECUTION_HOOKS + RECOVERY_PROMOTION_HOOKS .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((repo.path(), hook)); + .map_err(|_| "recovery promotion hook lock poisoned")? + .push((repo.path().canonicalize()?, hook)); let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err("expected late index edit to block recovery".into()); + return Err("expected promotion race to roll back recovery".into()); }; assert!( error .to_string() - .contains("at the recovery execution boundary") + .contains("during atomic recovery promotion") ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); let current = git.recovery_state()?; assert_eq!(current.head, expected.head); - assert_ne!(current.index, expected.index); - assert_eq!(current.worktree, expected.worktree); + assert_eq!(current.index, expected.index); assert_eq!(current.metadata, expected.metadata); assert_eq!(current.refs, expected.refs); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "promotion race\n" + ); Ok(()) } @@ -3980,7 +4352,7 @@ mod tests { let commit_hook = hooks.join("commit-msg"); fs::write( &commit_hook, - "#!/bin/sh\ntouch configured-commit-hook-ran\n", + "#!/bin/sh\npwd > configured-commit-hook-ran\n", )?; let transaction_hook = hooks.join("reference-transaction"); fs::write( @@ -4013,7 +4385,10 @@ mod tests { git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; assert_eq!(git.status()?.operation, None); - assert!(repo.path().join("configured-commit-hook-ran").exists()); + assert_eq!( + fs::read_to_string(repo.path().join("configured-commit-hook-ran"))?.trim(), + repo.path().canonicalize()?.to_string_lossy() + ); assert!(repo.path().join("configured-reference-hook-ran").exists()); assert_eq!(fs::read(config_path)?, config_before); for (path, contents) in hook_files.iter().zip(contents_before) { @@ -4061,12 +4436,17 @@ mod tests { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; + let hook_marker = repo.path().with_extension("hook-ran"); + let signing_marker = repo.path().with_extension("signing-ran"); let hooks = repo.path().join("hooks"); fs::create_dir(&hooks)?; let commit_msg_hook = hooks.join("commit-msg"); fs::write( &commit_msg_hook, - "#!/bin/sh\ntouch hook-ran\nprintf '\\377'\n", + format!( + "#!/bin/sh\ntouch {}\nprintf '\\377'\n", + shell_quote(&hook_marker.to_string_lossy()) + ), )?; let mut permissions = fs::metadata(&commit_msg_hook)?.permissions(); permissions.set_mode(0o755); @@ -4074,7 +4454,13 @@ mod tests { repo.run(["config", "core.hooksPath", "hooks"])?; let signing_program = repo.path().join("signing-program"); - fs::write(&signing_program, "#!/bin/sh\ntouch signing-ran\nexit 1\n")?; + fs::write( + &signing_program, + format!( + "#!/bin/sh\ntouch {}\nexit 1\n", + shell_quote(&signing_marker.to_string_lossy()) + ), + )?; let mut permissions = fs::metadata(&signing_program)?.permissions(); permissions.set_mode(0o755); fs::set_permissions(&signing_program, permissions)?; @@ -4101,8 +4487,8 @@ mod tests { }; assert!(args.ends_with(&["merge".to_owned(), "--continue".to_owned()])); assert!(format!("{stdout}{stderr}").contains('\u{fffd}')); - assert!(repo.path().join("hook-ran").exists()); - assert!(repo.path().join("signing-ran").exists()); + assert!(hook_marker.exists()); + assert!(signing_marker.exists()); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -5080,6 +5466,48 @@ mod tests { Ok((repo, original_head)) } + #[cfg(unix)] + fn install_recovery_prepared_barrier( + repo: &TempRepo, + ) -> Result<(PathBuf, PathBuf), Box> { + use std::os::unix::fs::PermissionsExt; + + let repo_path = repo.path(); + let parent = repo_path.parent().ok_or("test repository has no parent")?; + let name = repo_path + .file_name() + .ok_or("test repository has no file name")? + .to_string_lossy(); + let signal = parent.join(format!("{name}-recovery-child-prepared")); + let release = parent.join(format!("{name}-recovery-child-release")); + let _ = fs::remove_file(&signal); + let _ = fs::remove_file(&release); + let hook = repo.path().join(".git/hooks/reference-transaction"); + fs::write( + &hook, + format!( + "#!/bin/sh\nif [ \"$1\" = prepared ]; then\n touch {}\n while [ ! -e {} ]; do sleep 0.01; done\nfi\n", + shell_quote(&signal.to_string_lossy()), + shell_quote(&release.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(hook, permissions)?; + Ok((signal, release)) + } + + #[cfg(unix)] + fn wait_for_recovery_barrier(signal: &Path) -> Result<(), Box> { + for _ in 0..500 { + if signal.exists() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err("isolated recovery child did not reach its prepared hook".into()) + } + struct TempRepo { path: PathBuf, } From 29bd5b715a1fa1b38ba3180bacee0a1977de16e6 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 22:01:44 -0400 Subject: [PATCH 13/53] fix: fail closed without recovery capabilities --- crates/bitbygit-git/src/lib.rs | 308 ++++++++++++++++++++++++++++++--- crates/bitbygit-tui/src/lib.rs | 47 ++++- 2 files changed, 327 insertions(+), 28 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 0818d65..b0b06f6 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -45,6 +45,8 @@ const MAX_RECOVERY_SUBPROCESSES: usize = 12; const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; +const RECOVERY_CAPABILITY_UNAVAILABLE: &str = + "atomic recovery is unavailable because required platform capabilities are missing"; #[derive(Debug, Clone)] pub struct Git { @@ -312,6 +314,10 @@ impl Git { Ok(output) } + pub fn ensure_recovery_supported(&self) -> Result<(), GitError> { + RecoveryTransaction::supported_root(self).map(|_| ()) + } + pub fn push_current_branch( &self, remote: &str, @@ -1474,6 +1480,31 @@ struct RecoveryTransaction { impl RecoveryTransaction { fn prepare(git: &Git) -> Result { + let root = Self::supported_root(git)?; + let baseline = RecoveryGeneration::capture(&root)?; + let parent = root.parent().ok_or_else(|| { + recovery_transaction_blocked("repository root has no parent for isolated recovery") + })?; + let candidate = create_recovery_candidate(parent)?; + let mut transaction = Self { + root, + candidate, + baseline, + keep_candidate: false, + }; + transaction.copy_repository()?; + + let live_after_copy = RecoveryGeneration::capture(&transaction.root)?; + let copied = RecoveryGeneration::capture(&transaction.candidate)?; + if live_after_copy != transaction.baseline || copied != transaction.baseline { + return Err(recovery_transaction_blocked( + "repository changed while isolated recovery state was prepared", + )); + } + Ok(transaction) + } + + fn supported_root(git: &Git) -> Result { let root = git .repo_root()? .canonicalize() @@ -1503,28 +1534,12 @@ impl RecoveryTransaction { "atomic recovery does not support a shared common Git directory", )); } - - let baseline = RecoveryGeneration::capture(&root)?; let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; - let candidate = create_recovery_candidate(parent)?; - let mut transaction = Self { - root, - candidate, - baseline, - keep_candidate: false, - }; - transaction.copy_repository()?; - - let live_after_copy = RecoveryGeneration::capture(&transaction.root)?; - let copied = RecoveryGeneration::capture(&transaction.candidate)?; - if live_after_copy != transaction.baseline || copied != transaction.baseline { - return Err(recovery_transaction_blocked( - "repository changed while isolated recovery state was prepared", - )); - } - Ok(transaction) + run_recovery_capability_hook(&root)?; + ensure_recovery_platform_capabilities(parent)?; + Ok(root) } fn copy_repository(&mut self) -> Result<(), GitError> { @@ -1564,7 +1579,7 @@ impl RecoveryTransaction { "--fork", "sh", "-c", - "mount --bind \"$1\" \"$2\" && cd \"$2\" && shift 2 && exec git \"$@\"", + "mount --bind \"$1\" \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; cd \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; shift 2; exec git \"$@\"", "bitbygit-recovery", ]) .arg(&self.candidate) @@ -1591,6 +1606,14 @@ impl RecoveryTransaction { let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); if !output.status.success() { + if String::from_utf8_lossy(&output.stderr).lines().any(|line| { + line.starts_with("unshare: ") || line == "bitbygit: recovery namespace setup failed" + }) { + return Err(recovery_capability_unavailable(format!( + "the recovery namespace could not be established: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } return Err(GitError::GitFailed { args, status: output.status, @@ -1777,6 +1800,89 @@ fn create_recovery_candidate(parent: &Path) -> Result { )) } +#[cfg(target_os = "linux")] +fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> { + let source = create_recovery_probe_directory(parent)?; + let target = match create_recovery_probe_directory(parent) { + Ok(target) => target, + Err(error) => { + let _ = fs::remove_dir(&source); + return Err(error); + } + }; + let result = (|| { + let output = Command::new("unshare") + .args([ + "--user", + "--map-root-user", + "--mount", + "--fork", + "sh", + "-c", + "mount --bind \"$1\" \"$2\"", + "bitbygit-recovery-capability", + ]) + .arg(&source) + .arg(&target) + .output() + .map_err(|source| { + recovery_capability_unavailable(format!( + "the Linux user/mount namespace probe could not start: {source}" + )) + })?; + if !output.status.success() { + let detail = if output.stderr.is_empty() { + format!("status {}", output.status) + } else { + String::from_utf8_lossy(&output.stderr).trim().to_owned() + }; + return Err(recovery_capability_unavailable(format!( + "the Linux user/mount namespace probe failed: {detail}" + ))); + } + atomic_exchange_directories(&source, &target).map_err(|error| { + recovery_capability_unavailable(format!( + "same-filesystem atomic directory exchange is not available: {error}" + )) + }) + })(); + let _ = fs::remove_dir_all(&source); + let _ = fs::remove_dir_all(&target); + result +} + +#[cfg(not(target_os = "linux"))] +fn ensure_recovery_platform_capabilities(_parent: &Path) -> Result<(), GitError> { + Err(recovery_capability_unavailable( + "Linux user/mount namespaces and atomic directory exchange are required", + )) +} + +fn create_recovery_probe_directory(parent: &Path) -> Result { + for attempt in 0..100_u32 { + let candidate = parent.join(format!( + ".bitbygit-recovery-capability-{}-{}-{attempt}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(source) => { + return Err(recovery_capability_unavailable(format!( + "a same-filesystem capability probe directory could not be created: {source}" + ))); + } + } + } + Err(recovery_capability_unavailable( + "a same-filesystem capability probe directory could not be reserved", + )) +} + #[cfg(target_os = "linux")] fn atomic_exchange_directories(left: &Path, right: &Path) -> Result<(), GitError> { use rustix::fs::{CWD, RenameFlags, renameat_with}; @@ -1803,6 +1909,32 @@ fn recovery_transaction_blocked(message: impl Into) -> GitError { } } +fn recovery_capability_unavailable(detail: impl Display) -> GitError { + recovery_transaction_blocked(format!("{RECOVERY_CAPABILITY_UNAVAILABLE}: {detail}")) +} + +#[cfg(test)] +static RECOVERY_CAPABILITY_FAILURES: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_capability_hook(root: &Path) -> Result<(), GitError> { + if let Ok(mut failures) = RECOVERY_CAPABILITY_FAILURES.lock() + && let Some(index) = failures.iter().position(|target| target == root) + { + failures.swap_remove(index); + return Err(recovery_capability_unavailable( + "test platform capability failure", + )); + } + Ok(()) +} + +#[cfg(not(test))] +fn run_recovery_capability_hook(_root: &Path) -> Result<(), GitError> { + Ok(()) +} + #[cfg(test)] static RECOVERY_PROMOTION_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3094,6 +3226,33 @@ mod tests { static NEXT_REPO_ID: AtomicUsize = AtomicUsize::new(0); + fn recovery_capabilities_or_verify_fail_closed( + git: &Git, + operation: RepositoryOperation, + action: RecoveryAction, + expected: &RecoveryState, + ) -> Result> { + match git.ensure_recovery_supported() { + Ok(()) => Ok(true), + Err(GitError::Blocked { message }) + if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE) => + { + let root = git.repo_root()?.canonicalize()?; + let before = RecoveryGeneration::capture(&root)?; + let Err(error) = git.recover_exact(operation, action, expected) else { + return Err("recovery ran without its required platform capabilities".into()); + }; + assert!( + matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), + "{error}" + ); + assert_eq!(RecoveryGeneration::capture(&root)?, before); + Ok(false) + } + Err(error) => Err(error.into()), + } + } + #[test] fn parses_clean_status() -> Result<(), Box> { let status = parse_status( @@ -3670,6 +3829,14 @@ mod tests { merge_continue_repo.run(["add", "conflict.txt"])?; let git = Git::new(merge_continue_repo.path()); let state = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &state, + )? { + return Ok(()); + } git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; assert_eq!(git.status()?.operation, None); @@ -3713,6 +3880,35 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let root = repo.path().canonicalize()?; + let before = RecoveryGeneration::capture(&root)?; + RECOVERY_CAPABILITY_FAILURES + .lock() + .map_err(|_| "recovery capability failure lock poisoned")? + .push(root.clone()); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected unavailable recovery capabilities to block execution".into()); + }; + + assert!( + matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), + "{error}" + ); + assert_eq!(RecoveryGeneration::capture(&root)?, before); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn exact_recovery_rejects_state_changed_after_preview() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -3977,6 +4173,15 @@ mod tests { let expected = git.recovery_state()?; repo.write("notes.txt", "changed after preview\n")?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; assert_eq!(git.status()?.operation, None); @@ -4107,6 +4312,14 @@ mod tests { let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } let conflict = repo.path().join("conflict.txt"); let (scanned_tx, scanned_rx) = mpsc::channel(); let (edited_tx, edited_rx) = mpsc::channel(); @@ -4142,9 +4355,17 @@ mod tests { fn isolated_rebase_abort_preserves_ref_race_during_prepared_hook_without_partial_recovery() -> Result<(), Box> { let (repo, original_head) = prepare_rebase_conflict()?; - let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?; let preview_conflict = fs::read(repo.path().join("conflict.txt"))?; let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); @@ -4194,9 +4415,17 @@ mod tests { #[test] fn isolated_recovery_preserves_post_spawn_worktree_race() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; - let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let worker_path = repo.path(); let worker_state = expected.clone(); let worker = std::thread::spawn(move || { @@ -4241,9 +4470,17 @@ mod tests { #[test] fn isolated_recovery_preserves_post_spawn_index_race() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; - let (signal, release) = install_recovery_prepared_barrier(&repo)?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let (signal, release) = install_recovery_prepared_barrier(&repo)?; let original_blob = repo .git_stdout(["rev-parse", "HEAD:conflict.txt"])? .trim() @@ -4297,6 +4534,14 @@ mod tests { let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } let conflict = repo.path().join("conflict.txt"); let hook: RecoveryCaptureHook = Box::new(move || { let _ = fs::write(conflict, "promotion race\n"); @@ -4382,6 +4627,15 @@ mod tests { .map(fs::read) .collect::, _>>()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &state, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; assert_eq!(git.status()?.operation, None); @@ -4471,6 +4725,14 @@ mod tests { let git = Git::new(repo.path()); let state = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &state, + )? { + return Ok(()); + } let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state) else { diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index e005619..e3661e0 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -4167,6 +4167,18 @@ mod tests { use crossterm::event::{KeyModifiers, MouseEvent}; use ratatui::backend::TestBackend; + fn recovery_capabilities_available(repo: &std::path::Path) -> Result> { + match Git::new(repo).ensure_recovery_supported() { + Ok(()) => Ok(true), + Err(GitError::Blocked { message }) + if message.contains("required platform capabilities are missing") => + { + Ok(false) + } + Err(error) => Err(error.into()), + } + } + #[test] fn tab_cycles_visible_focus() { let mut app = App::new(); @@ -5069,17 +5081,30 @@ mod tests { .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) .map_err(std::io::Error::other)?; let paths = isolated_store_paths("recovery-audit")?; + let recovery_supported = recovery_capabilities_available(&repo)?; let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) .execute(&operation.plan, operation.context); - assert!(execution.succeeded(), "{}", execution.message()); - assert_eq!(Git::new(&repo).status()?.operation, None); + assert_eq!(execution.succeeded(), recovery_supported); + assert_eq!( + Git::new(&repo).status()?.operation, + (!recovery_supported).then_some(RepositoryOperation::Merge) + ); let entries = LocalStore::open(paths)?.list_audit_entries()?; assert_eq!(entries.len(), 2); assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); assert_eq!(entries[0].message, "pending"); - assert_eq!(entries[1].result, "ok"); + if recovery_supported { + assert_eq!(entries[1].result, "ok"); + } else { + assert_eq!(entries[1].result, "error"); + assert!( + execution + .message() + .contains("required platform capabilities are missing") + ); + } Ok(()) } @@ -5107,14 +5132,26 @@ mod tests { .plan_request(OperationRequest::Recover(request)) .map_err(std::io::Error::other)?; let paths = isolated_store_paths(&format!("recovery-{request:?}"))?; + let recovery_supported = recovery_capabilities_available(&repo)?; let execution = PlanExecutor::with_audit_paths(&repo, paths) .execute(&operation.plan, operation.context); - assert!( + assert_eq!( execution.succeeded(), + recovery_supported, "{request:?}: {}", execution.message() ); - assert_eq!(Git::new(repo).status()?.operation, None); + assert_eq!( + Git::new(repo).status()?.operation, + (!recovery_supported).then_some(recovery_git_operation(request)) + ); + if !recovery_supported { + assert!( + execution + .message() + .contains("required platform capabilities are missing") + ); + } } Ok(()) } From a0aaa02f6a57a76553558b8bb8cd77751cb87554 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Tue, 14 Jul 2026 22:16:11 -0400 Subject: [PATCH 14/53] fix: preserve bounded recovery generations --- crates/bitbygit-git/src/lib.rs | 352 ++++++++++++++++++++++++++++++++- 1 file changed, 342 insertions(+), 10 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index b0b06f6..313e56d 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -45,6 +45,19 @@ const MAX_RECOVERY_SUBPROCESSES: usize = 12; const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; +const RECOVERY_BACKUP_PREFIX: &str = ".bitbygit-recovery-backup-"; +const RECOVERY_CANDIDATE_PREFIX: &str = ".bitbygit-recovery-candidate-"; +const RECOVERY_GIT_ENVIRONMENT: &[&str] = &[ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_QUARANTINE_PATH", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", +]; const RECOVERY_CAPABILITY_UNAVAILABLE: &str = "atomic recovery is unavailable because required platform capabilities are missing"; @@ -292,6 +305,7 @@ impl Git { action: RecoveryAction, expected_state: &RecoveryState, ) -> Result { + ensure_recovery_environment_isolated()?; let current_state = self.recovery_state()?; if current_state != *expected_state { return Err(GitError::Blocked { @@ -303,18 +317,36 @@ impl Git { }); } let mut transaction = RecoveryTransaction::prepare(self)?; - let output = transaction.run_recovery( + let result = transaction.run_recovery( self, vec![ operation.label().to_owned(), format!("--{}", action.label()), ], - )?; - transaction.promote(self, ¤t_state)?; - Ok(output) + ); + match result { + Ok(mut output) => { + let backup = transaction.promote(self, ¤t_state)?; + append_recovery_backup_notice(&mut output.stderr, &backup); + Ok(output) + } + Err(mut error) + if operation == RepositoryOperation::Rebase + && matches!(action, RecoveryAction::Continue | RecoveryAction::Skip) + && transaction.has_advanced_rebase_conflict(¤t_state) => + { + let backup = transaction.promote(self, ¤t_state)?; + if let GitError::GitFailed { stderr, .. } = &mut error { + append_recovery_backup_notice(stderr, &backup); + } + Err(error) + } + Err(error) => Err(error), + } } pub fn ensure_recovery_supported(&self) -> Result<(), GitError> { + ensure_recovery_environment_isolated()?; RecoveryTransaction::supported_root(self).map(|_| ()) } @@ -1474,6 +1506,7 @@ impl Git { struct RecoveryTransaction { root: PathBuf, candidate: PathBuf, + backup_pointer: PathBuf, baseline: RecoveryGeneration, keep_candidate: bool, } @@ -1485,10 +1518,21 @@ impl RecoveryTransaction { let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; - let candidate = create_recovery_candidate(parent)?; + remove_previous_recovery_backup(&root)?; + let candidate = create_recovery_candidate(parent, &root)?; + let backup_pointer = recovery_backup_pointer_path(&root)?; + if let Err(source) = fs::write(&backup_pointer, candidate.as_os_str().as_encoded_bytes()) { + let _result = fs::remove_dir(&candidate); + let _result = fs::remove_file(recovery_candidate_owner_path(&candidate)); + return Err(recovery_transaction_io( + "record the retained recovery backup pointer", + source, + )); + } let mut transaction = Self { root, candidate, + backup_pointer, baseline, keep_candidate: false, }; @@ -1591,6 +1635,9 @@ impl RecoveryTransaction { .env("SSH_ASKPASS_REQUIRE", "never") .env("GIT_EDITOR", "true") .env("GIT_SEQUENCE_EDITOR", "true"); + for variable in RECOVERY_GIT_ENVIRONMENT { + command.env_remove(variable); + } if git.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { let ssh_executable = git .ssh_executable @@ -1628,7 +1675,25 @@ impl RecoveryTransaction { }) } - fn promote(&mut self, live_git: &Git, expected_state: &RecoveryState) -> Result<(), GitError> { + fn has_advanced_rebase_conflict(&self, expected_state: &RecoveryState) -> bool { + let candidate_git = Git::new(&self.candidate); + let Ok(candidate_state) = candidate_git.recovery_state() else { + return false; + }; + let Ok(candidate_status) = candidate_git.status() else { + return false; + }; + candidate_state != *expected_state + && candidate_state.operation == Some(RepositoryOperation::Rebase) + && candidate_status.operation == Some(RepositoryOperation::Rebase) + && !candidate_status.conflicted_files().is_empty() + } + + fn promote( + &mut self, + live_git: &Git, + expected_state: &RecoveryState, + ) -> Result { if live_git.recovery_state()? != *expected_state || RecoveryGeneration::capture(&self.root)? != self.baseline { @@ -1651,7 +1716,7 @@ impl RecoveryTransaction { "repository changed during atomic recovery promotion", )); } - Ok(()) + Ok(self.candidate.clone()) } } @@ -1659,6 +1724,8 @@ impl Drop for RecoveryTransaction { fn drop(&mut self) { if !self.keep_candidate { let _result = fs::remove_dir_all(&self.candidate); + let _result = fs::remove_file(recovery_candidate_owner_path(&self.candidate)); + let _result = fs::remove_file(&self.backup_pointer); } } } @@ -1774,10 +1841,10 @@ enum RecoveryGenerationValue { Symlink(PathBuf), } -fn create_recovery_candidate(parent: &Path) -> Result { +fn create_recovery_candidate(parent: &Path, root: &Path) -> Result { for attempt in 0..100_u32 { let candidate = parent.join(format!( - ".bitbygit-recovery-backup-{}-{}-{attempt}", + "{RECOVERY_CANDIDATE_PREFIX}{}-{}-{attempt}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1785,7 +1852,19 @@ fn create_recovery_candidate(parent: &Path) -> Result { .as_nanos() )); match fs::create_dir(&candidate) { - Ok(()) => return Ok(candidate), + Ok(()) => { + if let Err(source) = fs::write( + recovery_candidate_owner_path(&candidate), + root.as_os_str().as_encoded_bytes(), + ) { + let _result = fs::remove_dir(&candidate); + return Err(recovery_transaction_io( + "record isolated repository ownership", + source, + )); + } + return Ok(candidate); + } Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(source) => { return Err(recovery_transaction_io( @@ -1800,6 +1879,101 @@ fn create_recovery_candidate(parent: &Path) -> Result { )) } +fn recovery_candidate_owner_path(candidate: &Path) -> PathBuf { + let mut owner = candidate.as_os_str().to_os_string(); + owner.push(".owner"); + PathBuf::from(owner) +} + +fn recovery_backup_pointer_path(root: &Path) -> Result { + let name = root.file_name().ok_or_else(|| { + recovery_transaction_blocked("repository root has no name for retained recovery backup") + })?; + let mut pointer = OsString::from(RECOVERY_BACKUP_PREFIX); + pointer.push(name); + pointer.push(".pointer"); + Ok(root.with_file_name(pointer)) +} + +fn recovery_backup_from_pointer(expected_root: &Path) -> Result, GitError> { + let pointer = recovery_backup_pointer_path(expected_root)?; + let bytes = match fs::read(&pointer) { + Ok(bytes) => bytes, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(recovery_transaction_io( + "read the retained recovery backup pointer", + source, + )); + } + }; + let backup = path_from_bytes(&bytes); + let valid_location = backup.is_absolute() + && backup.parent() == expected_root.parent() + && backup.file_name().is_some_and(|name| { + name.as_encoded_bytes() + .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + }); + let backup_metadata = fs::symlink_metadata(&backup); + let valid_directory = backup_metadata + .as_ref() + .is_ok_and(|metadata| metadata.file_type().is_dir()); + let missing_directory = backup_metadata + .as_ref() + .is_err_and(|source| source.kind() == std::io::ErrorKind::NotFound); + let owner = recovery_candidate_owner_path(&backup); + let valid_owner = fs::symlink_metadata(&owner) + .is_ok_and(|metadata| metadata.file_type().is_file()) + && fs::read(&owner) + .is_ok_and(|contents| contents == expected_root.as_os_str().as_encoded_bytes()); + if !valid_location || (!valid_directory && !missing_directory) || !valid_owner { + return Err(recovery_transaction_blocked( + "the retained recovery backup pointer is invalid; refusing to remove it", + )); + } + Ok(Some(backup)) +} + +fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { + let Some(backup) = recovery_backup_from_pointer(root)? else { + return Ok(()); + }; + if let Err(source) = fs::remove_dir_all(&backup) + && source.kind() != std::io::ErrorKind::NotFound + { + return Err(recovery_transaction_io( + "remove the previous retained recovery backup", + source, + )); + } + fs::remove_file(recovery_backup_pointer_path(root)?) + .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; + let _result = fs::remove_file(recovery_candidate_owner_path(&backup)); + Ok(()) +} + +fn ensure_recovery_environment_isolated() -> Result<(), GitError> { + if let Some(variable) = RECOVERY_GIT_ENVIRONMENT + .iter() + .find(|variable| env::var_os(variable).is_some()) + { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support inherited {variable} repository state" + ))); + } + Ok(()) +} + +fn append_recovery_backup_notice(output: &mut String, backup: &Path) { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&format!( + "bitbygit: previous repository generation retained at {}; it will be removed before the next recovery attempt\n", + backup.display() + )); +} + #[cfg(target_os = "linux")] fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> { let source = create_recovery_probe_directory(parent)?; @@ -3880,6 +4054,86 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_promotes_and_reports_subsequent_conflicts() -> Result<(), Box> { + let continue_repo = prepare_two_conflict_rebase()?; + continue_repo.write("first.txt", "topic first\n")?; + continue_repo.run(["add", "first.txt"])?; + let continue_git = Git::new(continue_repo.path()); + let state = continue_git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &continue_git, + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &state, + )? { + return Ok(()); + } + + let Err(error) = continue_git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &state, + ) else { + return Err("expected exact continue to stop at the next conflict".into()); + }; + let GitError::GitFailed { stdout, stderr, .. } = error else { + return Err("expected the next conflict to remain a Git failure".into()); + }; + assert!(format!("{stdout}\n{stderr}").contains("second.txt")); + assert!(stderr.contains("previous repository generation retained at")); + let status = continue_git.status()?; + assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + status.conflicted_files()[0].path, + PathBuf::from("second.txt") + ); + let first_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? + .ok_or("missing retained recovery backup")?; + assert!(first_backup.is_dir()); + + continue_repo.write("second.txt", "topic second\n")?; + continue_repo.run(["add", "second.txt"])?; + let state = continue_git.recovery_state()?; + let output = continue_git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &state, + )?; + assert_eq!(continue_git.status()?.operation, None); + assert!( + output + .stderr + .contains("previous repository generation retained at") + ); + let second_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? + .ok_or("missing replacement recovery backup")?; + assert_ne!(second_backup, first_backup); + assert!(!first_backup.exists()); + assert!(second_backup.is_dir()); + + let skip_repo = prepare_two_conflict_rebase()?; + let skip_git = Git::new(skip_repo.path()); + let state = skip_git.recovery_state()?; + let Err(error) = + skip_git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &state) + else { + return Err("expected exact skip to stop at the next conflict".into()); + }; + let GitError::GitFailed { stdout, stderr, .. } = error else { + return Err("expected the conflict after skip to remain a Git failure".into()); + }; + assert!(format!("{stdout}\n{stderr}").contains("second.txt")); + assert!(stderr.contains("previous repository generation retained at")); + let status = skip_git.status()?; + assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + status.conflicted_files()[0].path, + PathBuf::from("second.txt") + ); + Ok(()) + } + #[test] fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() -> Result<(), Box> { @@ -3909,6 +4163,75 @@ mod tests { Ok(()) } + #[test] + fn recovery_rejects_external_git_storage_paths() -> Result<(), Box> { + if let (Some(repo), Some(variable)) = ( + env::var_os("BITBYGIT_TEST_EXTERNAL_GIT_REPO"), + env::var_os("BITBYGIT_TEST_EXTERNAL_GIT_VARIABLE"), + ) { + let variable = variable.to_string_lossy(); + let irrelevant_state = RecoveryState { + operation: Some(RepositoryOperation::Merge), + head: HeadTarget { + oid: None, + reference: None, + }, + index: Vec::new(), + worktree: Vec::new(), + metadata: Vec::new(), + refs: Vec::new(), + }; + let Err(error) = Git::new(repo).recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &irrelevant_state, + ) else { + return Err(format!("expected inherited {variable} to block recovery").into()); + }; + assert!(error.to_string().contains(variable.as_ref())); + return Ok(()); + } + + let (repo, _original_head) = prepare_merge_conflict()?; + let external_index = repo.path().with_extension("external-index"); + fs::copy(repo.path().join(".git/index"), &external_index)?; + let index_before = fs::read(&external_index)?; + let external_objects = repo.path().with_extension("external-objects"); + fs::create_dir(&external_objects)?; + fs::write(external_objects.join("sentinel"), "unchanged\n")?; + let objects_before = RecoveryGeneration::capture(&external_objects)?; + + for (variable, external_path) in [ + ("GIT_INDEX_FILE", external_index.as_path()), + ("GIT_OBJECT_DIRECTORY", external_objects.as_path()), + ] { + let output = Command::new(env::current_exe()?) + .args([ + "--exact", + "tests::recovery_rejects_external_git_storage_paths", + "--nocapture", + ]) + .env("BITBYGIT_TEST_EXTERNAL_GIT_REPO", repo.path()) + .env("BITBYGIT_TEST_EXTERNAL_GIT_VARIABLE", variable) + .env(variable, external_path) + .output()?; + assert!( + output.status.success(), + "child test for {variable} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + assert_eq!(fs::read(external_index)?, index_before); + assert_eq!( + RecoveryGeneration::capture(&external_objects)?, + objects_before + ); + fs::remove_file(repo.path().with_extension("external-index"))?; + fs::remove_dir_all(external_objects)?; + Ok(()) + } + #[test] fn exact_recovery_rejects_state_changed_after_preview() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -5885,6 +6208,15 @@ mod tests { impl Drop for TempRepo { fn drop(&mut self) { + if let Ok(root) = self.path.canonicalize() + && let Ok(Some(backup)) = recovery_backup_from_pointer(&root) + { + let _result = fs::remove_dir_all(&backup); + let _result = fs::remove_file(recovery_candidate_owner_path(&backup)); + if let Ok(pointer) = recovery_backup_pointer_path(&root) { + let _result = fs::remove_file(pointer); + } + } let _result = fs::remove_dir_all(&self.path); } } From 5f47c3143a768449bbc6b5fdeafcb205d0bda834 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 10:28:48 -0400 Subject: [PATCH 15/53] fix: close atomic recovery isolation gaps --- .github/workflows/ci.yml | 16 + Cargo.lock | 36 ++- README.md | 5 + crates/bitbygit-git/Cargo.toml | 1 + crates/bitbygit-git/src/lib.rs | 567 ++++++++++++++++++++++++++++++--- crates/bitbygit-tui/src/lib.rs | 76 ++++- docs/guardrails.md | 3 + 7 files changed, 662 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db4b37..807876b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,5 +34,21 @@ jobs: - name: Test run: cargo test --locked --workspace + - name: Enable Linux recovery isolation + run: | + if [ -e /proc/sys/kernel/unprivileged_userns_clone ]; then + sudo sysctl -w kernel.unprivileged_userns_clone=1 + fi + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Test production recovery path + env: + BITBYGIT_REQUIRE_RECOVERY_SUCCESS: "1" + run: | + cargo test --locked -p bitbygit-git tests::exact_recovery_executes_every_supported_action -- --exact + cargo test --locked -p bitbygit-tui tests::tui_reattaches_to_promoted_repository_after_recovery -- --exact + - name: Build run: cargo build --locked --workspace diff --git a/Cargo.lock b/Cargo.lock index 27bc333..4f88254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,8 +34,9 @@ name = "bitbygit-git" version = "0.1.0" dependencies = [ "libc", - "rustix", + "rustix 0.38.44", "sha2", + "xattr", ] [[package]] @@ -126,7 +127,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -312,6 +313,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -440,10 +447,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -761,6 +781,16 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index 9e3b1f3..8b6454a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ Prerequisites: - `git` available on `PATH`. - `gh` is optional for future GitHub workflows. +Conflict recovery is supported on Linux hosts that provide unprivileged user +and mount namespaces, bind mounts, and same-filesystem atomic directory +exchange. It fails closed without changing the repository on other platforms +or when those Linux capabilities are restricted. + Run local checks: ```sh diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 9e7951a..9bc3c68 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -17,3 +17,4 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] rustix = { version = "0.38", features = ["fs"] } +xattr = "1" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 313e56d..8c86d3d 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -4,7 +4,7 @@ use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; -use std::io::{BufReader, Read}; +use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; @@ -1521,13 +1521,15 @@ impl RecoveryTransaction { remove_previous_recovery_backup(&root)?; let candidate = create_recovery_candidate(parent, &root)?; let backup_pointer = recovery_backup_pointer_path(&root)?; - if let Err(source) = fs::write(&backup_pointer, candidate.as_os_str().as_encoded_bytes()) { + run_recovery_sidecar_hook(&backup_pointer); + if let Err(error) = write_new_recovery_sidecar( + &backup_pointer, + candidate.as_os_str().as_encoded_bytes(), + "record the retained recovery backup pointer", + ) { let _result = fs::remove_dir(&candidate); let _result = fs::remove_file(recovery_candidate_owner_path(&candidate)); - return Err(recovery_transaction_io( - "record the retained recovery backup pointer", - source, - )); + return Err(error); } let mut transaction = Self { root, @@ -1578,6 +1580,7 @@ impl RecoveryTransaction { "atomic recovery does not support a shared common Git directory", )); } + ensure_recovery_git_storage_isolated(&root)?; let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; @@ -1705,16 +1708,20 @@ impl RecoveryTransaction { run_recovery_promotion_hook(&self.root); atomic_exchange_directories(&self.root, &self.candidate)?; self.keep_candidate = true; - if RecoveryGeneration::capture(&self.candidate)? != self.baseline { + let old_generation = RecoveryGeneration::capture(&self.candidate); + if !matches!(&old_generation, Ok(generation) if generation == &self.baseline) { if let Err(error) = atomic_exchange_directories(&self.root, &self.candidate) { return Err(recovery_transaction_blocked(format!( "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" ))); } self.keep_candidate = false; - return Err(recovery_transaction_blocked( - "repository changed during atomic recovery promotion", - )); + return Err(recovery_transaction_blocked(match old_generation { + Ok(_) => "repository changed during atomic recovery promotion".to_owned(), + Err(error) => { + format!("repository metadata changed during atomic recovery promotion: {error}") + } + })); } Ok(self.candidate.clone()) } @@ -1732,11 +1739,20 @@ impl Drop for RecoveryTransaction { #[derive(Debug, PartialEq, Eq)] struct RecoveryGeneration { + root: RecoveryFilesystemMetadata, entries: Vec, } impl RecoveryGeneration { fn capture(root: &Path) -> Result { + let root_metadata = fs::symlink_metadata(root) + .map_err(|source| recovery_transaction_io("inspect repository root", source))?; + if !root_metadata.file_type().is_dir() { + return Err(recovery_transaction_blocked( + "repository root changed while its generation was captured", + )); + } + let root_value = recovery_filesystem_metadata(root, Path::new("."), &root_metadata, None)?; let mut entries = Vec::new(); let mut pending = vec![PathBuf::new()]; let mut path_bytes = 0_usize; @@ -1767,44 +1783,79 @@ impl RecoveryGeneration { let value = if file_type.is_dir() { pending.push(relative.clone()); RecoveryGenerationValue::Directory { - mode: recovery_file_mode(&metadata), + metadata: recovery_filesystem_metadata(&path, &relative, &metadata, None)?, } } else if file_type.is_file() { - file_bytes = file_bytes.saturating_add(metadata.len()); + let (mut file, opened_metadata) = + open_recovery_regular_file(&path, &relative, &metadata)?; + file_bytes = file_bytes.saturating_add(opened_metadata.len()); if file_bytes > MAX_RECOVERY_GENERATION_FILE_BYTES { return Err(recovery_transaction_blocked( "repository generation exceeds atomic recovery content bound", )); } - let identity = recovery_file_identity(&metadata); - let mut file = BufReader::new(fs::File::open(&path).map_err(|source| { - recovery_transaction_io("open repository generation file", source) - })?); + let file_metadata = recovery_filesystem_metadata( + &path, + &relative, + &opened_metadata, + Some(&file), + )?; + run_recovery_generation_file_read_hook(&path); let mut hasher = Sha256::new(); - std::io::copy(&mut file, &mut hasher).map_err(|source| { - recovery_transaction_io("hash repository generation file", source) - })?; + { + let mut reader = (&mut file).take(opened_metadata.len().saturating_add(1)); + let mut buffer = [0_u8; 64 * 1024]; + let mut bytes_read = 0_u64; + loop { + let read = reader.read(&mut buffer).map_err(|source| { + recovery_transaction_io("hash repository generation file", source) + })?; + if read == 0 { + break; + } + bytes_read = bytes_read.saturating_add(read as u64); + if bytes_read > opened_metadata.len() { + return Err(recovery_transaction_blocked(format!( + "repository generation file {} grew while it was captured", + relative.display() + ))); + } + hasher.update(&buffer[..read]); + } + } let current = fs::symlink_metadata(&path).map_err(|source| { recovery_transaction_io("recheck repository generation file", source) })?; - if !current.is_file() - || current.len() != metadata.len() - || recovery_file_mode(¤t) != recovery_file_mode(&metadata) - || recovery_file_identity(¤t) != identity - { + if !current.is_file() || current.len() != opened_metadata.len() { + return Err(recovery_transaction_blocked( + "repository generation changed while it was captured", + )); + } + let current_metadata = + recovery_filesystem_metadata(&path, &relative, ¤t, Some(&file))?; + if current_metadata != file_metadata { return Err(recovery_transaction_blocked( "repository generation changed while it was captured", )); } RecoveryGenerationValue::File { - mode: recovery_file_mode(&metadata), - size: metadata.len(), + metadata: file_metadata, + size: opened_metadata.len(), digest: hasher.finalize().into(), } } else if file_type.is_symlink() { - RecoveryGenerationValue::Symlink(fs::read_link(&path).map_err(|source| { - recovery_transaction_io("read repository generation symlink", source) - })?) + if relative.starts_with(".git") { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support symlinked Git storage {}", + relative.display() + ))); + } + RecoveryGenerationValue::Symlink { + metadata: recovery_filesystem_metadata(&path, &relative, &metadata, None)?, + target: fs::read_link(&path).map_err(|source| { + recovery_transaction_io("read repository generation symlink", source) + })?, + } } else { return Err(recovery_transaction_blocked(format!( "atomic recovery does not support special filesystem entry {}", @@ -1818,7 +1869,19 @@ impl RecoveryGeneration { } } entries.sort_by(|left, right| left.path.cmp(&right.path)); - Ok(Self { entries }) + let current_root = fs::symlink_metadata(root) + .map_err(|source| recovery_transaction_io("recheck repository root", source))?; + let current_root_value = + recovery_filesystem_metadata(root, Path::new("."), ¤t_root, None)?; + if current_root_value != root_value { + return Err(recovery_transaction_blocked( + "repository root changed while its generation was captured", + )); + } + Ok(Self { + root: root_value, + entries, + }) } } @@ -1831,14 +1894,193 @@ struct RecoveryGenerationEntry { #[derive(Debug, PartialEq, Eq)] enum RecoveryGenerationValue { Directory { - mode: u32, + metadata: RecoveryFilesystemMetadata, }, File { - mode: u32, + metadata: RecoveryFilesystemMetadata, size: u64, digest: [u8; 32], }, - Symlink(PathBuf), + Symlink { + metadata: RecoveryFilesystemMetadata, + target: PathBuf, + }, +} + +#[derive(Debug, PartialEq, Eq)] +struct RecoveryFilesystemMetadata { + mode: u32, + owner: (u32, u32), + modified: (i64, i64), + inode_flags: u32, +} + +fn recovery_filesystem_metadata( + path: &Path, + relative: &Path, + metadata: &fs::Metadata, + opened: Option<&fs::File>, +) -> Result { + ensure_no_recovery_extended_attributes(path, relative)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if metadata.file_type().is_file() && metadata.nlink() != 1 { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support hard-linked file {}", + relative.display() + ))); + } + Ok(RecoveryFilesystemMetadata { + mode: recovery_file_mode(metadata), + owner: (metadata.uid(), metadata.gid()), + modified: (metadata.mtime(), metadata.mtime_nsec()), + inode_flags: recovery_inode_flags(path, relative, metadata, opened)?, + }) + } + #[cfg(not(unix))] + { + let _ = (path, relative, opened); + Ok(RecoveryFilesystemMetadata { + mode: recovery_file_mode(metadata), + owner: (0, 0), + modified: (0, 0), + inode_flags: 0, + }) + } +} + +#[cfg(target_os = "linux")] +fn recovery_inode_flags( + path: &Path, + relative: &Path, + expected: &fs::Metadata, + opened: Option<&fs::File>, +) -> Result { + use std::os::unix::fs::OpenOptionsExt; + + let owned; + let file = if let Some(file) = opened { + file + } else if expected.file_type().is_symlink() { + return Ok(0); + } else { + owned = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|source| { + recovery_transaction_blocked(format!( + "repository metadata {} changed while it was opened: {source}", + relative.display() + )) + })?; + let current = owned.metadata().map_err(|source| { + recovery_transaction_io("inspect opened repository metadata", source) + })?; + if recovery_file_identity(¤t) != recovery_file_identity(expected) + || current.file_type() != expected.file_type() + { + return Err(recovery_transaction_blocked(format!( + "repository metadata {} changed while it was opened", + relative.display() + ))); + } + &owned + }; + Ok(rustix::fs::ioctl_getflags(file) + .map(|flags| flags.bits() & rustix::fs::IFlags::all().bits()) + .unwrap_or(0)) +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn recovery_inode_flags( + _path: &Path, + _relative: &Path, + _expected: &fs::Metadata, + _opened: Option<&fs::File>, +) -> Result { + Ok(0) +} + +#[cfg(target_os = "linux")] +fn ensure_no_recovery_extended_attributes(path: &Path, relative: &Path) -> Result<(), GitError> { + let mut attributes = xattr::list(path).map_err(|source| { + recovery_transaction_io("inspect repository extended attributes", source) + })?; + if attributes.next().is_some() { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support extended attributes or ACLs on {}", + relative.display() + ))); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_no_recovery_extended_attributes(_path: &Path, _relative: &Path) -> Result<(), GitError> { + Ok(()) +} + +fn ensure_recovery_git_storage_isolated(root: &Path) -> Result<(), GitError> { + let git_dir = root.join(".git"); + let mut pending = vec![git_dir]; + let mut entries = 0_usize; + let mut path_bytes = 0_usize; + while let Some(directory) = pending.pop() { + for child in fs::read_dir(&directory) + .map_err(|source| recovery_transaction_io("inspect Git storage", source))? + { + let child = + child.map_err(|source| recovery_transaction_io("inspect Git storage", source))?; + let path = child.path(); + entries = entries.saturating_add(1); + path_bytes = path_bytes.saturating_add(path.as_os_str().as_encoded_bytes().len()); + if entries > MAX_RECOVERY_GENERATION_ENTRIES + || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_transaction_blocked( + "Git storage exceeds atomic recovery entry or path bounds", + )); + } + let metadata = fs::symlink_metadata(&path) + .map_err(|source| recovery_transaction_io("inspect Git storage", source))?; + if metadata.file_type().is_symlink() { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support symlinked Git storage {}", + path.strip_prefix(root).unwrap_or(&path).display() + ))); + } + if metadata.file_type().is_dir() { + pending.push(path); + } + } + } + Ok(()) +} + +fn write_new_recovery_sidecar( + path: impl AsRef, + contents: &[u8], + action: &str, +) -> Result<(), GitError> { + let path = path.as_ref(); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|source| recovery_transaction_io(action, source))?; + if let Err(source) = file.write_all(contents) { + let _ = fs::remove_file(path); + return Err(recovery_transaction_io(action, source)); + } + Ok(()) } fn create_recovery_candidate(parent: &Path, root: &Path) -> Result { @@ -1853,15 +2095,13 @@ fn create_recovery_candidate(parent: &Path, root: &Path) -> Result { - if let Err(source) = fs::write( + if let Err(error) = write_new_recovery_sidecar( recovery_candidate_owner_path(&candidate), root.as_os_str().as_encoded_bytes(), + "record isolated repository ownership", ) { let _result = fs::remove_dir(&candidate); - return Err(recovery_transaction_io( - "record isolated repository ownership", - source, - )); + return Err(error); } return Ok(candidate); } @@ -2126,6 +2366,23 @@ fn run_recovery_promotion_hook(root: &Path) { #[cfg(not(test))] fn run_recovery_promotion_hook(_root: &Path) {} +#[cfg(test)] +static RECOVERY_SIDECAR_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_sidecar_hook(path: &Path) { + if let Ok(mut hooks) = RECOVERY_SIDECAR_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == path) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_sidecar_hook(_path: &Path) {} + #[derive(Default)] struct RecoveryCapture { output_bytes: usize, @@ -2507,6 +2764,23 @@ fn run_recovery_file_open_hook(path: &Path) { #[cfg(not(test))] fn run_recovery_file_open_hook(_path: &Path) {} +#[cfg(test)] +static RECOVERY_GENERATION_FILE_READ_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_generation_file_read_hook(path: &Path) { + if let Ok(mut hooks) = RECOVERY_GENERATION_FILE_READ_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == path) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_generation_file_read_hook(_path: &Path) {} + fn recovery_metadata_file<'a>( metadata: &'a [RecoveryMetadataEntry], relative: &str, @@ -3411,6 +3685,12 @@ mod tests { Err(GitError::Blocked { message }) if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE) => { + if env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { + return Err(format!( + "production recovery capabilities are required for this test: {message}" + ) + .into()); + } let root = git.repo_root()?.canonicalize()?; let before = RecoveryGeneration::capture(&root)?; let Err(error) = git.recover_exact(operation, action, expected) else { @@ -4163,6 +4443,217 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_rejects_symlinked_mutable_git_storage() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + for relative in ["objects", "refs", "index"] { + let (repo, _original_head) = prepare_merge_conflict()?; + let storage = repo.path().join(".git").join(relative); + let external = repo.path().with_extension(format!("external-{relative}")); + fs::rename(&storage, &external)?; + symlink(&external, &storage)?; + + let Err(error) = Git::new(repo.path()).ensure_recovery_supported() else { + return Err(format!("expected symlinked .git/{relative} to block recovery").into()); + }; + assert!(error.to_string().contains("symlinked Git storage")); + assert!(external.exists()); + + fs::remove_file(&storage)?; + fs::rename(&external, &storage)?; + } + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_pointer_race_cannot_overwrite_symlink_target() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let pointer = recovery_backup_pointer_path(&root)?; + let victim = repo.path().with_extension("pointer-victim"); + fs::write(&victim, "must remain unchanged\n")?; + let hook_pointer = pointer.clone(); + let hook_victim = victim.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = symlink(hook_victim, hook_pointer); + }); + RECOVERY_SIDECAR_HOOKS + .lock() + .map_err(|_| "recovery sidecar hook lock poisoned")? + .push((pointer.clone(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected hostile backup pointer to block recovery".into()); + }; + assert!(error.to_string().contains("backup pointer")); + assert_eq!(fs::read_to_string(&victim)?, "must remain unchanged\n"); + assert!(fs::symlink_metadata(&pointer)?.file_type().is_symlink()); + fs::remove_file(pointer)?; + fs::remove_file(victim)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_owner_sidecar_cannot_overwrite_symlink_target() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let repo = initialized_repo()?; + let owner = repo.path().with_extension("candidate.owner"); + let victim = repo.path().with_extension("owner-victim"); + fs::write(&victim, "must remain unchanged\n")?; + symlink(&victim, &owner)?; + + let Err(error) = write_new_recovery_sidecar(&owner, b"replacement", "record owner") else { + return Err("expected hostile owner sidecar to be rejected".into()); + }; + assert!(error.to_string().contains("record owner")); + assert_eq!(fs::read_to_string(&victim)?, "must remain unchanged\n"); + fs::remove_file(owner)?; + fs::remove_file(victim)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_generation_does_not_open_fifo_or_symlink_replacements() -> Result<(), Box> + { + use std::os::unix::fs::{FileTypeExt, symlink}; + + for replacement_kind in ["fifo", "symlink"] { + let repo = initialized_repo()?; + let path = repo.path().join("tracked.txt"); + fs::write(&path, "tracked\n")?; + let replacement = repo.path().join(format!("replacement-{replacement_kind}")); + if replacement_kind == "fifo" { + if !Command::new("mkfifo").arg(&replacement).status()?.success() { + return Err("mkfifo failed".into()); + } + } else { + symlink(repo.path().join(".git/config"), &replacement)?; + } + let destination = path.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::rename(replacement, destination); + }); + RECOVERY_FILE_OPEN_HOOKS + .lock() + .map_err(|_| "recovery file-open hook lock poisoned")? + .push((path.clone(), hook)); + + let Err(error) = RecoveryGeneration::capture(&repo.path()) else { + return Err( + format!("expected {replacement_kind} replacement to be rejected").into(), + ); + }; + assert!(matches!(error, GitError::Blocked { .. })); + let file_type = fs::symlink_metadata(path)?.file_type(); + assert!(if replacement_kind == "fifo" { + file_type.is_fifo() + } else { + file_type.is_symlink() + }); + } + Ok(()) + } + + #[test] + fn recovery_generation_bounds_file_growth_after_open() -> Result<(), Box> { + let repo = initialized_repo()?; + let path = repo.path().join("growing.txt"); + fs::write(&path, "before\n")?; + let growing = path.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + if let Ok(mut file) = fs::OpenOptions::new().append(true).open(growing) { + let _ = file.write_all(b"after\n"); + } + }); + RECOVERY_GENERATION_FILE_READ_HOOKS + .lock() + .map_err(|_| "recovery generation read hook lock poisoned")? + .push((path, hook)); + + let Err(error) = RecoveryGeneration::capture(&repo.path()) else { + return Err("expected file growth during generation capture to be rejected".into()); + }; + assert!(error.to_string().contains("grew while it was captured")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_generation_rejects_hard_links() -> Result<(), Box> { + let repo = initialized_repo()?; + let original = repo.path().join("original.txt"); + fs::write(&original, "linked\n")?; + fs::hard_link(&original, repo.path().join("linked.txt"))?; + + let Err(error) = RecoveryGeneration::capture(&repo.path()) else { + return Err("expected hard-linked files to block atomic recovery".into()); + }; + assert!(error.to_string().contains("hard-linked file")); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_rolls_back_concurrent_xattr_change() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let conflict = repo.path().join("conflict.txt"); + let changed = conflict.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = xattr::set(changed, "user.bitbygit-test", b"changed"); + }); + RECOVERY_PROMOTION_HOOKS + .lock() + .map_err(|_| "recovery promotion hook lock poisoned")? + .push((repo.path().canonicalize()?, hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected concurrent xattr change to roll back recovery".into()); + }; + assert!( + error + .to_string() + .contains("metadata changed during atomic recovery promotion") + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + xattr::get(conflict, "user.bitbygit-test")?, + Some(b"changed".to_vec()) + ); + Ok(()) + } + #[test] fn recovery_rejects_external_git_storage_paths() -> Result<(), Box> { if let (Some(repo), Some(variable)) = ( diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index e3661e0..0b20876 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -432,7 +432,18 @@ impl App { } fn execute_operation(&mut self, plan: OperationPlan, context: ExecutionContext) { - let message = execute_typed_plan(&plan, context); + let recovery_cwd = matches!(&plan.request, OperationRequest::Recover(_)).then(|| { + let cwd = current_dir(); + Git::new(&cwd).repo_root().unwrap_or(cwd) + }); + let mut message = execute_typed_plan(&plan, context); + if let Some(cwd) = recovery_cwd + && let Err(error) = std::env::set_current_dir(cwd) + { + message.push_str(&format!( + "\nUnable to reattach the TUI to the recovered repository: {error}" + )); + } if should_refresh_status_after(&plan) { self.refresh_status(); } @@ -451,11 +462,24 @@ impl App { } fn execute_prompt_sequence(&mut self, sequence: QueuedPromptSequence) { + let has_recovery = std::iter::once(&sequence.first.plan.request) + .chain(sequence.remaining_requests.iter()) + .any(|request| matches!(request, OperationRequest::Recover(_))); + let recovery_cwd = has_recovery.then(|| { + let cwd = current_dir(); + Git::new(&cwd).repo_root().unwrap_or(cwd) + }); let result = PromptSequenceExecutor::current().execute(sequence); + let reattach_error = recovery_cwd.and_then(|cwd| std::env::set_current_dir(cwd).err()); if result.should_refresh_status() { self.refresh_status(); } self.details = result.message(); + if let Some(error) = reattach_error { + self.details.push_str(&format!( + "\nUnable to reattach the TUI to the recovered repository: {error}" + )); + } } fn submit_prompt(&mut self) { @@ -4173,6 +4197,12 @@ mod tests { Err(GitError::Blocked { message }) if message.contains("required platform capabilities are missing") => { + if std::env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { + return Err(format!( + "production recovery capabilities are required for this test: {message}" + ) + .into()); + } Ok(false) } Err(error) => Err(error.into()), @@ -5108,6 +5138,50 @@ mod tests { Ok(()) } + #[test] + fn tui_reattaches_to_promoted_repository_after_recovery() -> Result<(), Box> { + if let Some(repo) = std::env::var_os("BITBYGIT_TEST_CWD_RECOVERY_REPO") { + let repo = PathBuf::from(repo); + let mut app = App::new(); + app.load_current_dir(); + app.submit_operation_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)); + app.handle_key(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); + + assert_eq!(app.repository_operation, None, "{}", app.details); + assert_eq!(current_dir().canonicalize()?, repo.canonicalize()?); + std::fs::write(repo.join("after-recovery.txt"), "promoted generation\n")?; + app.submit_operation_request(OperationRequest::StageAll); + app.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let status = Git::new(&repo).status()?; + assert!(status.staged_files().iter().any(|entry| { + entry.path == std::path::Path::new("after-recovery.txt") + && entry.index == ChangeKind::Added + })); + return Ok(()); + } + + let repo = merge_conflict_repo("cwd-recovery")?; + if !recovery_capabilities_available(&repo)? { + return Ok(()); + } + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "tests::tui_reattaches_to_promoted_repository_after_recovery", + "--nocapture", + ]) + .current_dir(&repo) + .env("BITBYGIT_TEST_CWD_RECOVERY_REPO", &repo) + .output()?; + assert!( + output.status.success(), + "cwd recovery child failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn confirmed_recovery_executes_all_supported_requests() -> Result<(), Box> { let merge_continue = merge_conflict_repo("recovery-merge-continue")?; diff --git a/docs/guardrails.md b/docs/guardrails.md index c76cfc3..9bb0292 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,6 +121,9 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. +Atomic conflict recovery is currently Linux-only and requires user and mount +namespaces plus same-filesystem atomic directory exchange. Unsupported or +restricted hosts block recovery before changing repository state. ## Confirmation Copy From 560ba61afcbce0cfd5e3dc01d1552d6a3e0c6c7f Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 10:47:38 -0400 Subject: [PATCH 16/53] fix: harden atomic recovery ownership --- crates/bitbygit-git/src/lib.rs | 427 +++++++++++++++++++++++++-------- 1 file changed, 322 insertions(+), 105 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 8c86d3d..b1abc7f 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -34,6 +34,7 @@ const RECOVERY_METADATA_PATHS: &[&str] = &[ "rebase-merge", "rebase-apply", ]; +const REBASE_PROGRESS_PATHS: &[&str] = &["rebase-merge/msgnum", "rebase-apply/next"]; const MAX_RECOVERY_RETAINED_BYTES: usize = 8 * 1024 * 1024; const MAX_RECOVERY_OUTPUT_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_FILE_BYTES_READ: u64 = 64 * 1024 * 1024; @@ -45,7 +46,7 @@ const MAX_RECOVERY_SUBPROCESSES: usize = 12; const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; -const RECOVERY_BACKUP_PREFIX: &str = ".bitbygit-recovery-backup-"; +const RECOVERY_BACKUP_POINTER: &str = "bitbygit-recovery-backup.pointer"; const RECOVERY_CANDIDATE_PREFIX: &str = ".bitbygit-recovery-candidate-"; const RECOVERY_GIT_ENVIRONMENT: &[&str] = &[ "GIT_ALTERNATE_OBJECT_DIRECTORIES", @@ -1514,23 +1515,16 @@ struct RecoveryTransaction { impl RecoveryTransaction { fn prepare(git: &Git) -> Result { let root = Self::supported_root(git)?; - let baseline = RecoveryGeneration::capture(&root)?; let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; remove_previous_recovery_backup(&root)?; - let candidate = create_recovery_candidate(parent, &root)?; - let backup_pointer = recovery_backup_pointer_path(&root)?; - run_recovery_sidecar_hook(&backup_pointer); - if let Err(error) = write_new_recovery_sidecar( - &backup_pointer, - candidate.as_os_str().as_encoded_bytes(), - "record the retained recovery backup pointer", - ) { - let _result = fs::remove_dir(&candidate); - let _result = fs::remove_file(recovery_candidate_owner_path(&candidate)); - return Err(error); - } + let baseline = RecoveryGeneration::capture(&root)?; + let (candidate, backup_identity) = create_recovery_candidate(parent, &root)?; + let backup_pointer = recovery_backup_pointer_path(&candidate)?; + let mut pointer_contents = candidate.as_os_str().as_encoded_bytes().to_vec(); + pointer_contents.push(0); + pointer_contents.extend_from_slice(&backup_identity); let mut transaction = Self { root, candidate, @@ -1547,6 +1541,12 @@ impl RecoveryTransaction { "repository changed while isolated recovery state was prepared", )); } + run_recovery_sidecar_hook(&recovery_backup_pointer_path(&transaction.root)?); + write_new_recovery_sidecar( + &transaction.backup_pointer, + &pointer_contents, + "record the retained recovery backup pointer", + )?; Ok(transaction) } @@ -1649,10 +1649,7 @@ impl RecoveryTransaction { .unwrap_or_else(|| "ssh".to_owned()); command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); } - let output = command.output().map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; + let output = run_bounded_recovery_command(&mut command, args.clone())?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); if !output.status.success() { @@ -1686,10 +1683,10 @@ impl RecoveryTransaction { let Ok(candidate_status) = candidate_git.status() else { return false; }; - candidate_state != *expected_state - && candidate_state.operation == Some(RepositoryOperation::Rebase) + candidate_state.operation == Some(RepositoryOperation::Rebase) && candidate_status.operation == Some(RepositoryOperation::Rebase) && !candidate_status.conflicted_files().is_empty() + && candidate_state.rebase_progressed_from(expected_state) } fn promote( @@ -2083,7 +2080,7 @@ fn write_new_recovery_sidecar( Ok(()) } -fn create_recovery_candidate(parent: &Path, root: &Path) -> Result { +fn create_recovery_candidate(parent: &Path, root: &Path) -> Result<(PathBuf, Vec), GitError> { for attempt in 0..100_u32 { let candidate = parent.join(format!( "{RECOVERY_CANDIDATE_PREFIX}{}-{}-{attempt}", @@ -2095,15 +2092,38 @@ fn create_recovery_candidate(parent: &Path, root: &Path) -> Result { + let root_identity = + recovery_file_identity(&fs::symlink_metadata(root).map_err(|source| { + recovery_transaction_io("identify repository generation", source) + })?); + let candidate_identity = + recovery_file_identity(&fs::symlink_metadata(&candidate).map_err( + |source| recovery_transaction_io("identify isolated repository", source), + )?); + let mut identity = Sha256::new(); + identity.update(root.as_os_str().as_encoded_bytes()); + identity.update(candidate.as_os_str().as_encoded_bytes()); + for value in [ + root_identity.0, + root_identity.1, + candidate_identity.0, + candidate_identity.1, + ] { + identity.update(value.to_le_bytes()); + } + let mut backup_identity = Vec::with_capacity(48); + backup_identity.extend_from_slice(&root_identity.0.to_le_bytes()); + backup_identity.extend_from_slice(&root_identity.1.to_le_bytes()); + backup_identity.extend_from_slice(&identity.finalize()); if let Err(error) = write_new_recovery_sidecar( recovery_candidate_owner_path(&candidate), - root.as_os_str().as_encoded_bytes(), + &backup_identity, "record isolated repository ownership", ) { let _result = fs::remove_dir(&candidate); return Err(error); } - return Ok(candidate); + return Ok((candidate, backup_identity)); } Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(source) => { @@ -2126,13 +2146,7 @@ fn recovery_candidate_owner_path(candidate: &Path) -> PathBuf { } fn recovery_backup_pointer_path(root: &Path) -> Result { - let name = root.file_name().ok_or_else(|| { - recovery_transaction_blocked("repository root has no name for retained recovery backup") - })?; - let mut pointer = OsString::from(RECOVERY_BACKUP_PREFIX); - pointer.push(name); - pointer.push(".pointer"); - Ok(root.with_file_name(pointer)) + Ok(root.join(".git").join(RECOVERY_BACKUP_POINTER)) } fn recovery_backup_from_pointer(expected_root: &Path) -> Result, GitError> { @@ -2147,25 +2161,32 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, )); } }; - let backup = path_from_bytes(&bytes); + let Some(separator) = bytes.iter().position(|byte| *byte == 0) else { + return Err(recovery_transaction_blocked( + "the retained recovery backup pointer is invalid; refusing to remove it", + )); + }; + let backup = path_from_bytes(&bytes[..separator]); + let identity = &bytes[separator + 1..]; let valid_location = backup.is_absolute() - && backup.parent() == expected_root.parent() && backup.file_name().is_some_and(|name| { name.as_encoded_bytes() .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) }); let backup_metadata = fs::symlink_metadata(&backup); - let valid_directory = backup_metadata - .as_ref() - .is_ok_and(|metadata| metadata.file_type().is_dir()); + let valid_directory = backup_metadata.as_ref().is_ok_and(|metadata| { + metadata.file_type().is_dir() + && identity.len() >= 16 + && recovery_file_identity(metadata).0.to_le_bytes() == identity[..8] + && recovery_file_identity(metadata).1.to_le_bytes() == identity[8..16] + }); let missing_directory = backup_metadata .as_ref() .is_err_and(|source| source.kind() == std::io::ErrorKind::NotFound); let owner = recovery_candidate_owner_path(&backup); let valid_owner = fs::symlink_metadata(&owner) .is_ok_and(|metadata| metadata.file_type().is_file()) - && fs::read(&owner) - .is_ok_and(|contents| contents == expected_root.as_os_str().as_encoded_bytes()); + && fs::read(&owner).is_ok_and(|contents| contents == identity); if !valid_location || (!valid_directory && !missing_directory) || !valid_owner { return Err(recovery_transaction_blocked( "the retained recovery backup pointer is invalid; refusing to remove it", @@ -2435,81 +2456,26 @@ impl RecoveryCapture { .env("GIT_ASKPASS", "") .env("SSH_ASKPASS", "") .env("SSH_ASKPASS_REQUIRE", "never") - .args(&args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = command.spawn().map_err(|source| GitError::Io { - args: display_args.clone(), - source, - })?; - let stdout = child.stdout.take().ok_or_else(|| GitError::Io { - args: display_args.clone(), - source: std::io::Error::other("recovery guard could not capture git stdout"), - })?; - let stderr = child.stderr.take().ok_or_else(|| GitError::Io { - args: display_args.clone(), - source: std::io::Error::other("recovery guard could not capture git stderr"), - })?; - let output_bytes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let stdout_bytes = std::sync::Arc::clone(&output_bytes); - let stderr_bytes = std::sync::Arc::clone(&output_bytes); - let stdout_reader = - std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes)); - let stderr_reader = - std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes)); - let status = loop { - if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { - let _ = child.kill(); - break child.wait(); - } - match child.try_wait() { - Ok(Some(status)) => break Ok(status), - Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), - Err(source) => break Err(source), - } - } - .map_err(|source| GitError::Io { - args: display_args.clone(), - source, - })?; - let stdout = stdout_reader - .join() - .map_err(|_| GitError::Io { - args: display_args.clone(), - source: std::io::Error::other("recovery guard stdout reader panicked"), - })? - .map_err(|source| GitError::Io { - args: display_args.clone(), - source, - })?; - let stderr = stderr_reader - .join() - .map_err(|_| GitError::Io { - args: display_args.clone(), - source: std::io::Error::other("recovery guard stderr reader panicked"), - })? - .map_err(|source| GitError::Io { - args: display_args.clone(), - source, - })?; - let bytes = output_bytes.load(std::sync::atomic::Ordering::Relaxed); + .args(&args); + let output = run_bounded_recovery_command(&mut command, display_args.clone())?; + let bytes = output.stdout.len().saturating_add(output.stderr.len()); self.output_bytes = self.output_bytes.saturating_add(bytes); - if bytes > MAX_RECOVERY_OUTPUT_BYTES || self.output_bytes > MAX_RECOVERY_OUTPUT_BYTES { + if self.output_bytes > MAX_RECOVERY_OUTPUT_BYTES { return Err(recovery_bound_error(format!( "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" ))); } - if status.success() { - return Ok(Some(stdout)); + if output.status.success() { + return Ok(Some(output.stdout)); } - if allow_missing && status.code() == Some(1) { + if allow_missing && output.status.code() == Some(1) { return Ok(None); } Err(GitError::GitFailed { args: display_args, - status, - stdout: String::from_utf8_lossy(&stdout).into_owned(), - stderr: String::from_utf8_lossy(&stderr).into_owned(), + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), }) } @@ -2554,6 +2520,78 @@ impl RecoveryCapture { } } +fn run_bounded_recovery_command( + command: &mut Command, + args: Vec, +) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let stdout = child.stdout.take().ok_or_else(|| GitError::Io { + args: args.clone(), + source: std::io::Error::other("recovery command could not capture stdout"), + })?; + let stderr = child.stderr.take().ok_or_else(|| GitError::Io { + args: args.clone(), + source: std::io::Error::other("recovery command could not capture stderr"), + })?; + let output_bytes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let stdout_bytes = std::sync::Arc::clone(&output_bytes); + let stderr_bytes = std::sync::Arc::clone(&output_bytes); + let stdout_reader = + std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes)); + let stderr_reader = + std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes)); + let status = loop { + if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { + let _ = child.kill(); + break child.wait(); + } + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), + Err(source) => break Err(source), + } + } + .map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let stdout = stdout_reader + .join() + .map_err(|_| GitError::Io { + args: args.clone(), + source: std::io::Error::other("recovery stdout reader panicked"), + })? + .map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let stderr = stderr_reader + .join() + .map_err(|_| GitError::Io { + args: args.clone(), + source: std::io::Error::other("recovery stderr reader panicked"), + })? + .map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { + return Err(recovery_bound_error(format!( + "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" + ))); + } + Ok(RawProcessOutput { + args, + status, + stdout, + stderr, + }) +} + fn read_bounded_recovery_output( mut stream: impl Read, total: std::sync::Arc, @@ -3132,6 +3170,19 @@ pub struct RecoveryState { refs: Vec, } +impl RecoveryState { + fn rebase_progressed_from(&self, expected: &Self) -> bool { + self.head != expected.head + || REBASE_PROGRESS_PATHS.iter().any(|path| { + let current = recovery_metadata_file(&self.metadata, path) + .and_then(parse_rebase_progress_marker); + let previous = recovery_metadata_file(&expected.metadata, path) + .and_then(parse_rebase_progress_marker); + matches!((current, previous), (Some(current), Some(previous)) if current > previous) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RecoveryWorktreeEntry { path: PathBuf, @@ -3168,6 +3219,10 @@ enum RecoveryMetadataValue { Symlink(PathBuf), } +fn parse_rebase_progress_marker(contents: &[u8]) -> Option { + std::str::from_utf8(contents).ok()?.trim().parse().ok() +} + impl RecoveryAction { fn label(self) -> &'static str { match self { @@ -4414,6 +4469,149 @@ mod tests { Ok(()) } + #[test] + fn failed_rebase_skip_without_progress_does_not_promote() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); + let lock = git.git_path("index.lock")?; + fs::write(&lock, "block skip before it advances\n")?; + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Rebase, + RecoveryAction::Skip, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let before = RecoveryGeneration::capture(&root)?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &expected) + else { + return Err("expected locked rebase skip to fail".into()); + }; + + assert!(matches!(error, GitError::GitFailed { .. }), "{error}"); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(RecoveryGeneration::capture(&root)?, before); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + fs::remove_file(lock)?; + Ok(()) + } + + #[test] + fn recovery_bounds_hook_output_without_promoting() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write( + &hook, + "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null\n", + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected noisy recovery hook to exceed the output bound".into()); + }; + + assert!(error.to_string().contains("Git output bytes exceed")); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[test] + fn retained_backup_follows_repository_rename() -> Result<(), Box> { + let (mut repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let first_backup = recovery_backup_from_pointer(&repo.path().canonicalize()?)? + .ok_or("missing retained recovery backup")?; + let renamed = repo.path().with_extension("renamed"); + fs::rename(repo.path(), &renamed)?; + repo.path = renamed; + repo.run_allow_failure(["merge", "other"])?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + + assert!(!first_backup.exists()); + assert!(recovery_backup_from_pointer(&repo.path().canonicalize()?)?.is_some()); + Ok(()) + } + + #[test] + fn same_path_replacement_does_not_own_retained_backup() -> Result<(), Box> { + let (mut repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let root = repo.path().canonicalize()?; + let backup = + recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; + let former = root.with_extension("former"); + fs::rename(&root, &former)?; + repo.path = former.clone(); + let output = Command::new("git") + .args(["init", "-b", "main"]) + .arg(&root) + .output()?; + if !output.status.success() { + return Err(format!( + "failed to create replacement repository: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + remove_previous_recovery_backup(&root.canonicalize()?)?; + + assert!(backup.is_dir()); + fs::remove_dir_all(&root)?; + fs::rename(&former, &root)?; + repo.path = root; + Ok(()) + } + #[test] fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() -> Result<(), Box> { @@ -4490,7 +4688,27 @@ mod tests { let hook_pointer = pointer.clone(); let hook_victim = victim.clone(); let hook: RecoveryCaptureHook = Box::new(move || { - let _ = symlink(hook_victim, hook_pointer); + let Some(root) = hook_pointer.parent().and_then(Path::parent) else { + return; + }; + let Some(parent) = root.parent() else { + return; + }; + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + if entry + .file_name() + .as_encoded_bytes() + .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + { + let _ = symlink( + &hook_victim, + entry.path().join(".git").join(RECOVERY_BACKUP_POINTER), + ); + } + } }); RECOVERY_SIDECAR_HOOKS .lock() @@ -4504,8 +4722,7 @@ mod tests { }; assert!(error.to_string().contains("backup pointer")); assert_eq!(fs::read_to_string(&victim)?, "must remain unchanged\n"); - assert!(fs::symlink_metadata(&pointer)?.file_type().is_symlink()); - fs::remove_file(pointer)?; + assert!(!pointer.exists()); fs::remove_file(victim)?; Ok(()) } From 83c420fd4cf6b75fd3fc143b04f0c98db7233c5f Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 10:58:33 -0400 Subject: [PATCH 17/53] fix: secure atomic recovery copies --- crates/bitbygit-git/src/lib.rs | 197 +++++++++++++++++++++++++++++++-- 1 file changed, 185 insertions(+), 12 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index b1abc7f..f1cc4ca 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1535,7 +1535,7 @@ impl RecoveryTransaction { transaction.copy_repository()?; let live_after_copy = RecoveryGeneration::capture(&transaction.root)?; - let copied = RecoveryGeneration::capture(&transaction.candidate)?; + let copied = RecoveryGeneration::capture_isolated(&transaction.candidate)?; if live_after_copy != transaction.baseline || copied != transaction.baseline { return Err(recovery_transaction_blocked( "repository changed while isolated recovery state was prepared", @@ -1591,7 +1591,7 @@ impl RecoveryTransaction { fn copy_repository(&mut self) -> Result<(), GitError> { let output = Command::new("cp") - .args(["-a", "--reflink=auto", "--"]) + .args(["-a", "--no-preserve=links", "--reflink=auto", "--"]) .arg(self.root.join(".")) .arg(&self.candidate) .output() @@ -1742,6 +1742,17 @@ struct RecoveryGeneration { impl RecoveryGeneration { fn capture(root: &Path) -> Result { + Self::capture_with_hard_linked_git_objects(root, true) + } + + fn capture_isolated(root: &Path) -> Result { + Self::capture_with_hard_linked_git_objects(root, false) + } + + fn capture_with_hard_linked_git_objects( + root: &Path, + allow_hard_linked_git_objects: bool, + ) -> Result { let root_metadata = fs::symlink_metadata(root) .map_err(|source| recovery_transaction_io("inspect repository root", source))?; if !root_metadata.file_type().is_dir() { @@ -1749,7 +1760,13 @@ impl RecoveryGeneration { "repository root changed while its generation was captured", )); } - let root_value = recovery_filesystem_metadata(root, Path::new("."), &root_metadata, None)?; + let root_value = recovery_filesystem_metadata( + root, + Path::new("."), + &root_metadata, + None, + allow_hard_linked_git_objects, + )?; let mut entries = Vec::new(); let mut pending = vec![PathBuf::new()]; let mut path_bytes = 0_usize; @@ -1780,7 +1797,13 @@ impl RecoveryGeneration { let value = if file_type.is_dir() { pending.push(relative.clone()); RecoveryGenerationValue::Directory { - metadata: recovery_filesystem_metadata(&path, &relative, &metadata, None)?, + metadata: recovery_filesystem_metadata( + &path, + &relative, + &metadata, + None, + allow_hard_linked_git_objects, + )?, } } else if file_type.is_file() { let (mut file, opened_metadata) = @@ -1796,6 +1819,7 @@ impl RecoveryGeneration { &relative, &opened_metadata, Some(&file), + allow_hard_linked_git_objects, )?; run_recovery_generation_file_read_hook(&path); let mut hasher = Sha256::new(); @@ -1828,8 +1852,13 @@ impl RecoveryGeneration { "repository generation changed while it was captured", )); } - let current_metadata = - recovery_filesystem_metadata(&path, &relative, ¤t, Some(&file))?; + let current_metadata = recovery_filesystem_metadata( + &path, + &relative, + ¤t, + Some(&file), + allow_hard_linked_git_objects, + )?; if current_metadata != file_metadata { return Err(recovery_transaction_blocked( "repository generation changed while it was captured", @@ -1848,7 +1877,13 @@ impl RecoveryGeneration { ))); } RecoveryGenerationValue::Symlink { - metadata: recovery_filesystem_metadata(&path, &relative, &metadata, None)?, + metadata: recovery_filesystem_metadata( + &path, + &relative, + &metadata, + None, + allow_hard_linked_git_objects, + )?, target: fs::read_link(&path).map_err(|source| { recovery_transaction_io("read repository generation symlink", source) })?, @@ -1868,8 +1903,13 @@ impl RecoveryGeneration { entries.sort_by(|left, right| left.path.cmp(&right.path)); let current_root = fs::symlink_metadata(root) .map_err(|source| recovery_transaction_io("recheck repository root", source))?; - let current_root_value = - recovery_filesystem_metadata(root, Path::new("."), ¤t_root, None)?; + let current_root_value = recovery_filesystem_metadata( + root, + Path::new("."), + ¤t_root, + None, + allow_hard_linked_git_objects, + )?; if current_root_value != root_value { return Err(recovery_transaction_blocked( "repository root changed while its generation was captured", @@ -1917,13 +1957,17 @@ fn recovery_filesystem_metadata( relative: &Path, metadata: &fs::Metadata, opened: Option<&fs::File>, + allow_hard_linked_git_objects: bool, ) -> Result { ensure_no_recovery_extended_attributes(path, relative)?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; - if metadata.file_type().is_file() && metadata.nlink() != 1 { + if metadata.file_type().is_file() + && metadata.nlink() != 1 + && !(allow_hard_linked_git_objects && relative.starts_with(".git/objects")) + { return Err(recovery_transaction_blocked(format!( "atomic recovery does not support hard-linked file {}", relative.display() @@ -1938,7 +1982,7 @@ fn recovery_filesystem_metadata( } #[cfg(not(unix))] { - let _ = (path, relative, opened); + let _ = (path, relative, opened, allow_hard_linked_git_objects); Ok(RecoveryFilesystemMetadata { mode: recovery_file_mode(metadata), owner: (0, 0), @@ -2090,7 +2134,13 @@ fn create_recovery_candidate(parent: &Path, root: &Path) -> Result<(PathBuf, Vec .unwrap_or_default() .as_nanos() )); - match fs::create_dir(&candidate) { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&candidate) { Ok(()) => { let root_identity = recovery_file_identity(&fs::symlink_metadata(root).map_err(|source| { @@ -4389,6 +4439,86 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn exact_recovery_supports_hard_linked_objects_from_local_clone() -> Result<(), Box> + { + use std::os::unix::fs::MetadataExt; + + let source = initialized_repo()?; + let clone = TempRepo::new()?; + let output = Command::new("git") + .args(["clone", "--local", "--"]) + .arg(source.path()) + .arg(clone.path()) + .output()?; + if !output.status.success() { + return Err(format!( + "local git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + clone.run(["config", "user.email", "bitbygit@example.invalid"])?; + clone.run(["config", "user.name", "bitbygit test"])?; + + let oid = clone.git_stdout(["rev-parse", "HEAD"])?; + let oid = oid.trim(); + let object = PathBuf::from(".git/objects") + .join(&oid[..2]) + .join(&oid[2..]); + assert!(fs::metadata(clone.path().join(&object))?.nlink() > 1); + + let root = clone.path().canonicalize()?; + let baseline = RecoveryGeneration::capture(&root)?; + let parent = root.parent().ok_or("local clone has no parent")?; + let (candidate, _) = create_recovery_candidate(parent, &root)?; + let mut transaction = RecoveryTransaction { + root: root.clone(), + backup_pointer: recovery_backup_pointer_path(&candidate)?, + candidate, + baseline, + keep_candidate: false, + }; + transaction.copy_repository()?; + assert_eq!( + RecoveryGeneration::capture_isolated(&transaction.candidate)?, + transaction.baseline + ); + assert_eq!( + fs::metadata(transaction.candidate.join(&object))?.nlink(), + 1 + ); + drop(transaction); + + clone.write("conflict.txt", "base\n")?; + clone.run(["add", "conflict.txt"])?; + clone.run(["commit", "-m", "base"])?; + clone.run(["switch", "-c", "other"])?; + clone.write("conflict.txt", "other\n")?; + clone.run(["commit", "-am", "other"])?; + clone.run(["switch", "main"])?; + clone.write("conflict.txt", "main\n")?; + clone.run(["commit", "-am", "main"])?; + clone.run_allow_failure(["merge", "other"])?; + let git = Git::new(clone.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + + assert_eq!(git.status()?.operation, None); + assert_eq!(fs::metadata(clone.path().join(object))?.nlink(), 1); + Ok(()) + } + #[test] fn exact_rebase_promotes_and_reports_subsequent_conflicts() -> Result<(), Box> { let continue_repo = prepare_two_conflict_rebase()?; @@ -4694,6 +4824,13 @@ mod tests { let Some(parent) = root.parent() else { return; }; + let Ok(root_metadata) = fs::symlink_metadata(root) else { + return; + }; + let root_identity = recovery_file_identity(&root_metadata); + let mut expected_owner = Vec::with_capacity(16); + expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); + expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); let Ok(entries) = fs::read_dir(parent) else { return; }; @@ -4702,6 +4839,8 @@ mod tests { .file_name() .as_encoded_bytes() .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + && fs::read(recovery_candidate_owner_path(&entry.path())) + .is_ok_and(|owner| owner.starts_with(&expected_owner)) { let _ = symlink( &hook_victim, @@ -4748,6 +4887,40 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_candidate_is_private_under_shared_parent() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let parent = TempRepo::new()?; + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o755))?; + let root = parent.path().join("private-repository"); + fs::create_dir(&root)?; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?; + fs::write(root.join("secret.txt"), "private\n")?; + fs::set_permissions(root.join("secret.txt"), fs::Permissions::from_mode(0o644))?; + + let (candidate, _) = create_recovery_candidate(&parent.path(), &root)?; + + assert_eq!( + fs::metadata(parent.path())?.permissions().mode() & 0o777, + 0o755 + ); + assert_eq!(fs::metadata(&root)?.permissions().mode() & 0o777, 0o700); + assert_eq!( + fs::metadata(&candidate)?.permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(recovery_candidate_owner_path(&candidate))? + .permissions() + .mode() + & 0o777, + 0o600 + ); + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_generation_does_not_open_fifo_or_symlink_replacements() -> Result<(), Box> From b219d3269461b6a249f872e2c7e9ea57ff1a1b1d Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 11:12:14 -0400 Subject: [PATCH 18/53] fix: close recovery isolation escapes --- crates/bitbygit-git/Cargo.toml | 2 +- crates/bitbygit-git/src/lib.rs | 178 ++++++++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 6 deletions(-) diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 9bc3c68..97336c0 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -16,5 +16,5 @@ sha2 = "0.10" libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] -rustix = { version = "0.38", features = ["fs"] } +rustix = { version = "0.38", features = ["fs", "process"] } xattr = "1" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index f1cc4ca..9bdda79 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -24,6 +24,7 @@ const COMMIT_HOOKS: &[&str] = &[ "post-commit", ]; const RECOVERY_METADATA_PATHS: &[&str] = &[ + "objects/info/alternates", "MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", @@ -2066,6 +2067,16 @@ fn ensure_no_recovery_extended_attributes(_path: &Path, _relative: &Path) -> Res fn ensure_recovery_git_storage_isolated(root: &Path) -> Result<(), GitError> { let git_dir = root.join(".git"); + let alternates = git_dir.join("objects/info/alternates"); + match fs::symlink_metadata(&alternates) { + Ok(_) => { + return Err(recovery_transaction_blocked( + "atomic recovery does not support Git alternate object storage", + )); + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(recovery_transaction_io("inspect Git alternates", source)), + } let mut pending = vec![git_dir]; let mut entries = 0_usize; let mut path_bytes = 0_usize; @@ -2161,9 +2172,11 @@ fn create_recovery_candidate(parent: &Path, root: &Path) -> Result<(PathBuf, Vec ] { identity.update(value.to_le_bytes()); } - let mut backup_identity = Vec::with_capacity(48); + let mut backup_identity = Vec::with_capacity(64); backup_identity.extend_from_slice(&root_identity.0.to_le_bytes()); backup_identity.extend_from_slice(&root_identity.1.to_le_bytes()); + backup_identity.extend_from_slice(&candidate_identity.0.to_le_bytes()); + backup_identity.extend_from_slice(&candidate_identity.1.to_le_bytes()); backup_identity.extend_from_slice(&identity.finalize()); if let Err(error) = write_new_recovery_sidecar( recovery_candidate_owner_path(&candidate), @@ -2224,6 +2237,13 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) }); let backup_metadata = fs::symlink_metadata(&backup); + let expected_root_metadata = fs::symlink_metadata(expected_root); + let valid_expected_root = expected_root_metadata.as_ref().is_ok_and(|metadata| { + metadata.file_type().is_dir() + && identity.len() >= 32 + && recovery_file_identity(metadata).0.to_le_bytes() == identity[16..24] + && recovery_file_identity(metadata).1.to_le_bytes() == identity[24..32] + }); let valid_directory = backup_metadata.as_ref().is_ok_and(|metadata| { metadata.file_type().is_dir() && identity.len() >= 16 @@ -2237,7 +2257,11 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, let valid_owner = fs::symlink_metadata(&owner) .is_ok_and(|metadata| metadata.file_type().is_file()) && fs::read(&owner).is_ok_and(|contents| contents == identity); - if !valid_location || (!valid_directory && !missing_directory) || !valid_owner { + if !valid_location + || !valid_expected_root + || (!valid_directory && !missing_directory) + || !valid_owner + { return Err(recovery_transaction_blocked( "the retained recovery backup pointer is invalid; refusing to remove it", )); @@ -2574,6 +2598,11 @@ fn run_bounded_recovery_command( command: &mut Command, args: Vec, ) -> Result { + #[cfg(target_os = "linux")] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = command.spawn().map_err(|source| GitError::Io { args: args.clone(), @@ -2596,8 +2625,16 @@ fn run_bounded_recovery_command( std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes)); let status = loop { if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { - let _ = child.kill(); - break child.wait(); + #[cfg(target_os = "linux")] + let termination = terminate_recovery_process_group(&mut child); + #[cfg(not(target_os = "linux"))] + let termination = child.kill(); + let status = child.wait(); + termination.map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + break status; } match child.try_wait() { Ok(Some(status)) => break Ok(status), @@ -2642,6 +2679,21 @@ fn run_bounded_recovery_command( }) } +#[cfg(target_os = "linux")] +fn terminate_recovery_process_group(child: &mut std::process::Child) -> std::io::Result<()> { + let raw_pid = i32::try_from(child.id()) + .map_err(|_| std::io::Error::other("recovery process id is out of range"))?; + let pid = rustix::process::Pid::from_raw(raw_pid) + .ok_or_else(|| std::io::Error::other("recovery process id is invalid"))?; + match rustix::process::kill_process_group(pid, rustix::process::Signal::Kill) { + Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()), + Err(source) => { + let _ = child.kill(); + Err(std::io::Error::from_raw_os_error(source.raw_os_error())) + } + } +} + fn read_bounded_recovery_output( mut stream: impl Read, total: std::sync::Arc, @@ -4631,6 +4683,7 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] #[test] fn recovery_bounds_hook_output_without_promoting() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; @@ -4639,9 +4692,15 @@ mod tests { repo.write("conflict.txt", "resolved\n")?; repo.run(["add", "conflict.txt"])?; let hook = repo.path().join(".git/hooks/commit-msg"); + let hook_pid = repo.path().with_extension("noisy-hook-pid"); + let late_side_effect = repo.path().with_extension("noisy-hook-late-effect"); fs::write( &hook, - "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null\n", + format!( + "#!/bin/sh\nprintf '%s\\n' $$ > {}\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\ntouch {}\n", + shell_quote(&hook_pid.to_string_lossy()), + shell_quote(&late_side_effect.to_string_lossy()) + ), )?; let mut permissions = fs::metadata(&hook)?.permissions(); permissions.set_mode(0o755); @@ -4657,6 +4716,7 @@ mod tests { return Ok(()); } + let started = std::time::Instant::now(); let Err(error) = git.recover_exact( RepositoryOperation::Merge, RecoveryAction::Continue, @@ -4666,8 +4726,26 @@ mod tests { }; assert!(error.to_string().contains("Git output bytes exceed")); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "recovery waited for the noisy hook instead of terminating its process group" + ); + let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; + let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid noisy hook pid")?; + for _ in 0..200 { + if rustix::process::test_kill_process(pid).is_err() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + rustix::process::test_kill_process(pid).is_err(), + "noisy recovery hook survived output-bound termination" + ); + assert!(!late_side_effect.exists()); assert_eq!(git.recovery_state()?, expected); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_file(hook_pid)?; Ok(()) } @@ -4742,6 +4820,48 @@ mod tests { Ok(()) } + #[test] + fn copied_repository_does_not_own_retained_backup() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let root = repo.path().canonicalize()?; + let backup = + recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; + let copy = TempRepo::new()?; + let output = Command::new("cp") + .args(["-a", "--"]) + .arg(root.join(".")) + .arg(copy.path()) + .output()?; + if !output.status.success() { + return Err(format!( + "repository copy failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + let copied_root = copy.path().canonicalize()?; + + let Err(error) = remove_previous_recovery_backup(&copied_root) else { + return Err("expected copied backup pointer to be rejected".into()); + }; + + assert!(error.to_string().contains("backup pointer is invalid")); + assert!(backup.is_dir()); + assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup)); + Ok(()) + } + #[test] fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() -> Result<(), Box> { @@ -4795,6 +4915,54 @@ mod tests { Ok(()) } + #[test] + fn recovery_rejects_local_alternate_object_storage() -> Result<(), Box> { + let source = initialized_repo()?; + let clone = TempRepo::new()?; + let output = Command::new("git") + .args(["clone", "--shared", "--"]) + .arg(source.path()) + .arg(clone.path()) + .output()?; + if !output.status.success() { + return Err(format!( + "shared git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + assert!(clone.path().join(".git/objects/info/alternates").is_file()); + + let Err(error) = Git::new(clone.path()).ensure_recovery_supported() else { + return Err("expected shared clone alternates to block recovery".into()); + }; + + assert!(error.to_string().contains("alternate object storage")); + Ok(()) + } + + #[test] + fn exact_recovery_rejects_relative_alternate_removed_after_preview() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let alternates = repo.path().join(".git/objects/info/alternates"); + fs::write(&alternates, "../../external-objects\n")?; + fs::create_dir(repo.path().join("external-objects"))?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + fs::remove_file(&alternates)?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected removed Git alternates to invalidate recovery preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_pointer_race_cannot_overwrite_symlink_target() -> Result<(), Box> { From c152899a7c289a96166230b555d82b89f74c8ffe Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 11:24:49 -0400 Subject: [PATCH 19/53] fix: classify recovery policy operations --- crates/bitbygit-core/src/policy.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/bitbygit-core/src/policy.rs b/crates/bitbygit-core/src/policy.rs index d283587..ece9ca2 100644 --- a/crates/bitbygit-core/src/policy.rs +++ b/crates/bitbygit-core/src/policy.rs @@ -83,8 +83,13 @@ pub const fn operation_family(operation: OperationKind) -> OperationFamily { OperationKind::Branches => OperationFamily::Branches, OperationKind::CheckoutBranch => OperationFamily::Checkout, OperationKind::CreateBranch => OperationFamily::CreateBranch, - OperationKind::MergeFastForward => OperationFamily::Merge, - OperationKind::Rebase => OperationFamily::Rebase, + OperationKind::MergeFastForward + | OperationKind::MergeContinue + | OperationKind::MergeAbort => OperationFamily::Merge, + OperationKind::Rebase + | OperationKind::RebaseContinue + | OperationKind::RebaseAbort + | OperationKind::RebaseSkip => OperationFamily::Rebase, OperationKind::OpenPullRequest => OperationFamily::OpenPullRequest, } } @@ -224,7 +229,12 @@ mod tests { (OperationKind::CheckoutBranch, OperationFamily::Checkout), (OperationKind::CreateBranch, OperationFamily::CreateBranch), (OperationKind::MergeFastForward, OperationFamily::Merge), + (OperationKind::MergeContinue, OperationFamily::Merge), + (OperationKind::MergeAbort, OperationFamily::Merge), (OperationKind::Rebase, OperationFamily::Rebase), + (OperationKind::RebaseContinue, OperationFamily::Rebase), + (OperationKind::RebaseAbort, OperationFamily::Rebase), + (OperationKind::RebaseSkip, OperationFamily::Rebase), ( OperationKind::OpenPullRequest, OperationFamily::OpenPullRequest, From 87c246c524779dfb428464a3bd9afb08bebc5937 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 11:47:24 -0400 Subject: [PATCH 20/53] fix: preserve changed recovery backups --- crates/bitbygit-git/src/lib.rs | 289 ++++++++++++++++++++++++++++++--- 1 file changed, 269 insertions(+), 20 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 9bdda79..331a86e 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -47,6 +47,9 @@ const MAX_RECOVERY_SUBPROCESSES: usize = 12; const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_RECOVERY_BACKUP_POINTER_BYTES: u64 = 16 * 1024; +const RECOVERY_BACKUP_IDENTITY_BYTES: usize = 64; +const RECOVERY_GENERATION_DIGEST_BYTES: usize = 32; const RECOVERY_BACKUP_POINTER: &str = "bitbygit-recovery-backup.pointer"; const RECOVERY_CANDIDATE_PREFIX: &str = ".bitbygit-recovery-candidate-"; const RECOVERY_GIT_ENVIRONMENT: &[&str] = &[ @@ -1526,6 +1529,7 @@ impl RecoveryTransaction { let mut pointer_contents = candidate.as_os_str().as_encoded_bytes().to_vec(); pointer_contents.push(0); pointer_contents.extend_from_slice(&backup_identity); + pointer_contents.extend_from_slice(&baseline.digest()); let mut transaction = Self { root, candidate, @@ -1921,6 +1925,40 @@ impl RecoveryGeneration { entries, }) } + + fn digest(&self) -> [u8; RECOVERY_GENERATION_DIGEST_BYTES] { + let mut digest = Sha256::new(); + update_recovery_filesystem_metadata_digest(&mut digest, &self.root); + digest.update((self.entries.len() as u64).to_le_bytes()); + for entry in &self.entries { + update_recovery_digest_bytes(&mut digest, entry.path.as_os_str().as_encoded_bytes()); + match &entry.value { + RecoveryGenerationValue::Directory { metadata } => { + digest.update([0]); + update_recovery_filesystem_metadata_digest(&mut digest, metadata); + } + RecoveryGenerationValue::File { + metadata, + size, + digest: file_digest, + } => { + digest.update([1]); + update_recovery_filesystem_metadata_digest(&mut digest, metadata); + digest.update(size.to_le_bytes()); + digest.update(file_digest); + } + RecoveryGenerationValue::Symlink { metadata, target } => { + digest.update([2]); + update_recovery_filesystem_metadata_digest(&mut digest, metadata); + update_recovery_digest_bytes( + &mut digest, + target.as_os_str().as_encoded_bytes(), + ); + } + } + } + digest.finalize().into() + } } #[derive(Debug, PartialEq, Eq)] @@ -1953,6 +1991,23 @@ struct RecoveryFilesystemMetadata { inode_flags: u32, } +fn update_recovery_filesystem_metadata_digest( + digest: &mut Sha256, + metadata: &RecoveryFilesystemMetadata, +) { + digest.update(metadata.mode.to_le_bytes()); + digest.update(metadata.owner.0.to_le_bytes()); + digest.update(metadata.owner.1.to_le_bytes()); + digest.update(metadata.modified.0.to_le_bytes()); + digest.update(metadata.modified.1.to_le_bytes()); + digest.update(metadata.inode_flags.to_le_bytes()); +} + +fn update_recovery_digest_bytes(digest: &mut Sha256, bytes: &[u8]) { + digest.update((bytes.len() as u64).to_le_bytes()); + digest.update(bytes); +} + fn recovery_filesystem_metadata( path: &Path, relative: &Path, @@ -2212,25 +2267,40 @@ fn recovery_backup_pointer_path(root: &Path) -> Result { Ok(root.join(".git").join(RECOVERY_BACKUP_POINTER)) } -fn recovery_backup_from_pointer(expected_root: &Path) -> Result, GitError> { +struct RecoveryBackup { + path: PathBuf, + generation_digest: [u8; RECOVERY_GENERATION_DIGEST_BYTES], +} + +fn recovery_backup_record_from_pointer( + expected_root: &Path, +) -> Result, GitError> { let pointer = recovery_backup_pointer_path(expected_root)?; - let bytes = match fs::read(&pointer) { - Ok(bytes) => bytes, + let pointer_metadata = match fs::symlink_metadata(&pointer) { + Ok(metadata) => metadata, Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(source) => { return Err(recovery_transaction_io( - "read the retained recovery backup pointer", + "inspect the retained recovery backup pointer", source, )); } }; + let bytes = read_recovery_sidecar( + &pointer, + &pointer_metadata, + MAX_RECOVERY_BACKUP_POINTER_BYTES, + None, + )?; let Some(separator) = bytes.iter().position(|byte| *byte == 0) else { - return Err(recovery_transaction_blocked( - "the retained recovery backup pointer is invalid; refusing to remove it", - )); + return Err(invalid_recovery_backup_pointer()); }; let backup = path_from_bytes(&bytes[..separator]); - let identity = &bytes[separator + 1..]; + let record = &bytes[separator + 1..]; + if record.len() != RECOVERY_BACKUP_IDENTITY_BYTES + RECOVERY_GENERATION_DIGEST_BYTES { + return Err(invalid_recovery_backup_pointer()); + } + let (identity, generation_digest) = record.split_at(RECOVERY_BACKUP_IDENTITY_BYTES); let valid_location = backup.is_absolute() && backup.file_name().is_some_and(|name| { name.as_encoded_bytes() @@ -2240,13 +2310,11 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, let expected_root_metadata = fs::symlink_metadata(expected_root); let valid_expected_root = expected_root_metadata.as_ref().is_ok_and(|metadata| { metadata.file_type().is_dir() - && identity.len() >= 32 && recovery_file_identity(metadata).0.to_le_bytes() == identity[16..24] && recovery_file_identity(metadata).1.to_le_bytes() == identity[24..32] }); let valid_directory = backup_metadata.as_ref().is_ok_and(|metadata| { metadata.file_type().is_dir() - && identity.len() >= 16 && recovery_file_identity(metadata).0.to_le_bytes() == identity[..8] && recovery_file_identity(metadata).1.to_le_bytes() == identity[8..16] }); @@ -2255,25 +2323,86 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, .is_err_and(|source| source.kind() == std::io::ErrorKind::NotFound); let owner = recovery_candidate_owner_path(&backup); let valid_owner = fs::symlink_metadata(&owner) - .is_ok_and(|metadata| metadata.file_type().is_file()) - && fs::read(&owner).is_ok_and(|contents| contents == identity); + .ok() + .and_then(|metadata| { + read_recovery_sidecar( + &owner, + &metadata, + RECOVERY_BACKUP_IDENTITY_BYTES as u64, + Some(RECOVERY_BACKUP_IDENTITY_BYTES as u64), + ) + .ok() + }) + .is_some_and(|contents| contents == identity); if !valid_location || !valid_expected_root || (!valid_directory && !missing_directory) || !valid_owner { - return Err(recovery_transaction_blocked( - "the retained recovery backup pointer is invalid; refusing to remove it", - )); + return Err(invalid_recovery_backup_pointer()); + } + let mut digest = [0_u8; RECOVERY_GENERATION_DIGEST_BYTES]; + digest.copy_from_slice(generation_digest); + Ok(Some(RecoveryBackup { + path: backup, + generation_digest: digest, + })) +} + +#[cfg(test)] +fn recovery_backup_from_pointer(expected_root: &Path) -> Result, GitError> { + Ok(recovery_backup_record_from_pointer(expected_root)?.map(|backup| backup.path)) +} + +fn read_recovery_sidecar( + path: &Path, + expected: &fs::Metadata, + max_bytes: u64, + exact_bytes: Option, +) -> Result, GitError> { + if !expected.file_type().is_file() + || expected.len() > max_bytes + || exact_bytes.is_some_and(|bytes| expected.len() != bytes) + { + return Err(invalid_recovery_backup_pointer()); } - Ok(Some(backup)) + let (mut file, opened) = open_recovery_regular_file(path, path, expected)?; + let mut contents = Vec::with_capacity(opened.len() as usize); + (&mut file) + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents) + .map_err(|source| { + recovery_transaction_io("read retained recovery backup metadata", source) + })?; + if contents.len() as u64 != opened.len() || contents.len() as u64 > max_bytes { + return Err(invalid_recovery_backup_pointer()); + } + ensure_recovery_regular_file_path_unchanged(path, path, &opened)?; + Ok(contents) +} + +fn invalid_recovery_backup_pointer() -> GitError { + recovery_transaction_blocked( + "the retained recovery backup pointer is invalid; refusing to remove it", + ) } fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { - let Some(backup) = recovery_backup_from_pointer(root)? else { + let Some(backup) = recovery_backup_record_from_pointer(root)? else { return Ok(()); }; - if let Err(source) = fs::remove_dir_all(&backup) + match RecoveryGeneration::capture(&backup.path) { + Ok(generation) if generation.digest() != backup.generation_digest => { + return Err(recovery_transaction_blocked(format!( + "the retained recovery backup at {} changed after promotion; refusing to remove it", + backup.path.display() + ))); + } + Ok(_) => {} + Err(_) if !backup.path.exists() => {} + Err(error) => return Err(error), + } + if let Err(source) = fs::remove_dir_all(&backup.path) && source.kind() != std::io::ErrorKind::NotFound { return Err(recovery_transaction_io( @@ -2283,7 +2412,7 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { } fs::remove_file(recovery_backup_pointer_path(root)?) .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; - let _result = fs::remove_file(recovery_candidate_owner_path(&backup)); + let _result = fs::remove_file(recovery_candidate_owner_path(&backup.path)); Ok(()) } @@ -2304,7 +2433,7 @@ fn append_recovery_backup_notice(output: &mut String, backup: &Path) { output.push('\n'); } output.push_str(&format!( - "bitbygit: previous repository generation retained at {}; it will be removed before the next recovery attempt\n", + "bitbygit: previous repository generation retained at {}; it will be removed before the next recovery attempt only if unchanged\n", backup.display() )); } @@ -4749,6 +4878,53 @@ mod tests { Ok(()) } + #[test] + fn changed_retained_backup_blocks_later_recovery() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let mut stale_file = fs::OpenOptions::new() + .write(true) + .open(repo.path().join("conflict.txt"))?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let root = repo.path().canonicalize()?; + let backup = + recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; + stale_file.set_len(0)?; + stale_file.write_all(b"late stale-descriptor write\n")?; + stale_file.sync_all()?; + assert_eq!( + fs::read_to_string(backup.join("conflict.txt"))?, + "late stale-descriptor write\n" + ); + + repo.run_allow_failure(["merge", "other"])?; + let expected = git.recovery_state()?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed retained backup to block later recovery".into()); + }; + + assert!(error.to_string().contains("changed after promotion")); + assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); + assert_eq!( + fs::read_to_string(backup.join("conflict.txt"))?, + "late stale-descriptor write\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn retained_backup_follows_repository_rename() -> Result<(), Box> { let (mut repo, _original_head) = prepare_merge_conflict()?; @@ -5055,6 +5231,79 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_backup_sidecars_reject_fifo_and_oversized_files() -> Result<(), Box> { + let fifo_repo = initialized_repo()?; + let fifo_root = fifo_repo.path().canonicalize()?; + let fifo_pointer = recovery_backup_pointer_path(&fifo_root)?; + if !Command::new("mkfifo") + .arg(&fifo_pointer) + .status()? + .success() + { + return Err("mkfifo failed".into()); + } + let started = std::time::Instant::now(); + let Err(error) = recovery_backup_from_pointer(&fifo_root) else { + return Err("expected FIFO backup pointer to be rejected".into()); + }; + assert!(matches!(error, GitError::Blocked { .. })); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + fs::remove_file(fifo_pointer)?; + + let oversized_repo = initialized_repo()?; + let oversized_root = oversized_repo.path().canonicalize()?; + let oversized_pointer = recovery_backup_pointer_path(&oversized_root)?; + fs::write( + &oversized_pointer, + vec![0_u8; MAX_RECOVERY_BACKUP_POINTER_BYTES as usize + 1], + )?; + let Err(error) = recovery_backup_from_pointer(&oversized_root) else { + return Err("expected oversized backup pointer to be rejected".into()); + }; + assert!(matches!(error, GitError::Blocked { .. })); + fs::remove_file(oversized_pointer)?; + + let owner_repo = initialized_repo()?; + let owner_root = owner_repo.path().canonicalize()?; + let parent = owner_root.parent().ok_or("test repository has no parent")?; + let backup = parent.join(format!( + "{RECOVERY_CANDIDATE_PREFIX}hostile-owner-{}", + NEXT_REPO_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&backup)?; + let backup_identity = recovery_file_identity(&fs::symlink_metadata(&backup)?); + let root_identity = recovery_file_identity(&fs::symlink_metadata(&owner_root)?); + let mut identity = Vec::with_capacity(RECOVERY_BACKUP_IDENTITY_BYTES); + for value in [ + backup_identity.0, + backup_identity.1, + root_identity.0, + root_identity.1, + ] { + identity.extend_from_slice(&value.to_le_bytes()); + } + identity.extend_from_slice(&[0_u8; 32]); + let owner = recovery_candidate_owner_path(&backup); + fs::write(&owner, vec![0_u8; RECOVERY_BACKUP_IDENTITY_BYTES + 1])?; + let mut pointer_contents = backup.as_os_str().as_encoded_bytes().to_vec(); + pointer_contents.push(0); + pointer_contents.extend_from_slice(&identity); + pointer_contents.extend_from_slice(&[0_u8; RECOVERY_GENERATION_DIGEST_BYTES]); + fs::write(recovery_backup_pointer_path(&owner_root)?, pointer_contents)?; + + let Err(error) = recovery_backup_from_pointer(&owner_root) else { + return Err("expected oversized backup owner sidecar to be rejected".into()); + }; + assert!(matches!(error, GitError::Blocked { .. })); + assert!(backup.is_dir()); + fs::remove_file(recovery_backup_pointer_path(&owner_root)?)?; + fs::remove_file(owner)?; + fs::remove_dir(backup)?; + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_candidate_is_private_under_shared_parent() -> Result<(), Box> { From 0cc1a1e350c1b76b038b53fa9d5988d24fe294dc Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 12:06:21 -0400 Subject: [PATCH 21/53] fix: reject nested recovery mounts --- .github/workflows/ci.yml | 1 + crates/bitbygit-git/src/lib.rs | 257 ++++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 807876b..fd37341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: BITBYGIT_REQUIRE_RECOVERY_SUCCESS: "1" run: | cargo test --locked -p bitbygit-git tests::exact_recovery_executes_every_supported_action -- --exact + cargo test --locked -p bitbygit-git tests::exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data -- --exact cargo test --locked -p bitbygit-tui tests::tui_reattaches_to_promoted_repository_after_recovery -- --exact - name: Build diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 331a86e..0f8fa86 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1586,6 +1586,7 @@ impl RecoveryTransaction { )); } ensure_recovery_git_storage_isolated(&root)?; + ensure_recovery_mount_tree_isolated(&root)?; let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; @@ -1595,6 +1596,7 @@ impl RecoveryTransaction { } fn copy_repository(&mut self) -> Result<(), GitError> { + ensure_recovery_mount_tree_isolated(&self.root)?; let output = Command::new("cp") .args(["-a", "--no-preserve=links", "--reflink=auto", "--"]) .arg(self.root.join(".")) @@ -1708,6 +1710,7 @@ impl RecoveryTransaction { } run_recovery_promotion_hook(&self.root); + ensure_recovery_mount_tree_isolated(&self.root)?; atomic_exchange_directories(&self.root, &self.candidate)?; self.keep_candidate = true; let old_generation = RecoveryGeneration::capture(&self.candidate); @@ -1731,7 +1734,7 @@ impl RecoveryTransaction { impl Drop for RecoveryTransaction { fn drop(&mut self) { - if !self.keep_candidate { + if !self.keep_candidate && ensure_recovery_mount_tree_isolated(&self.candidate).is_ok() { let _result = fs::remove_dir_all(&self.candidate); let _result = fs::remove_file(recovery_candidate_owner_path(&self.candidate)); let _result = fs::remove_file(&self.backup_pointer); @@ -1765,11 +1768,13 @@ impl RecoveryGeneration { "repository root changed while its generation was captured", )); } + let root_mount_id = recovery_mount_id(root, Path::new("."))?; let root_value = recovery_filesystem_metadata( root, Path::new("."), &root_metadata, None, + root_mount_id, allow_hard_linked_git_objects, )?; let mut entries = Vec::new(); @@ -1807,6 +1812,7 @@ impl RecoveryGeneration { &relative, &metadata, None, + root_mount_id, allow_hard_linked_git_objects, )?, } @@ -1824,6 +1830,7 @@ impl RecoveryGeneration { &relative, &opened_metadata, Some(&file), + root_mount_id, allow_hard_linked_git_objects, )?; run_recovery_generation_file_read_hook(&path); @@ -1862,6 +1869,7 @@ impl RecoveryGeneration { &relative, ¤t, Some(&file), + root_mount_id, allow_hard_linked_git_objects, )?; if current_metadata != file_metadata { @@ -1887,6 +1895,7 @@ impl RecoveryGeneration { &relative, &metadata, None, + root_mount_id, allow_hard_linked_git_objects, )?, target: fs::read_link(&path).map_err(|source| { @@ -1913,6 +1922,7 @@ impl RecoveryGeneration { Path::new("."), ¤t_root, None, + root_mount_id, allow_hard_linked_git_objects, )?; if current_root_value != root_value { @@ -2013,8 +2023,15 @@ fn recovery_filesystem_metadata( relative: &Path, metadata: &fs::Metadata, opened: Option<&fs::File>, + root_mount_id: u64, allow_hard_linked_git_objects: bool, ) -> Result { + if recovery_mount_id(path, relative)? != root_mount_id { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support mounted filesystem entry {}", + relative.display() + ))); + } ensure_no_recovery_extended_attributes(path, relative)?; #[cfg(unix)] { @@ -2048,6 +2065,87 @@ fn recovery_filesystem_metadata( } } +#[cfg(target_os = "linux")] +fn recovery_mount_id(path: &Path, relative: &Path) -> Result { + use rustix::fs::{AtFlags, CWD, StatxFlags, statx}; + + let result = statx( + CWD, + path, + AtFlags::NO_AUTOMOUNT | AtFlags::SYMLINK_NOFOLLOW, + StatxFlags::MNT_ID, + ) + .map_err(|source| { + recovery_capability_unavailable(format!( + "the mount identity of {} could not be inspected: {source}", + relative.display() + )) + })?; + if result.stx_mask & StatxFlags::MNT_ID.bits() == 0 { + return Err(recovery_capability_unavailable( + "Linux mount identity inspection is not available", + )); + } + Ok(result.stx_mnt_id) +} + +#[cfg(not(target_os = "linux"))] +fn recovery_mount_id(_path: &Path, _relative: &Path) -> Result { + Ok(0) +} + +fn ensure_recovery_mount_tree_isolated(root: &Path) -> Result<(), GitError> { + let root_metadata = fs::symlink_metadata(root) + .map_err(|source| recovery_transaction_io("inspect recovery cleanup root", source))?; + if !root_metadata.file_type().is_dir() { + return Err(recovery_transaction_blocked( + "recovery directory changed before its mount boundary was checked", + )); + } + let root_mount_id = recovery_mount_id(root, Path::new("."))?; + let mut pending = vec![PathBuf::new()]; + let mut entries = 0_usize; + let mut path_bytes = 0_usize; + while let Some(relative_dir) = pending.pop() { + let directory = root.join(&relative_dir); + let children = fs::read_dir(&directory) + .map_err(|source| recovery_transaction_io("inspect recovery mount tree", source))?; + for child in children { + let child = child + .map_err(|source| recovery_transaction_io("inspect recovery mount tree", source))?; + let relative = relative_dir.join(child.file_name()); + entries = entries.saturating_add(1); + path_bytes = path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); + if entries > MAX_RECOVERY_GENERATION_ENTRIES + || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_transaction_blocked( + "repository generation exceeds atomic recovery entry or path bounds", + )); + } + let path = child.path(); + let metadata = fs::symlink_metadata(&path).map_err(|source| { + recovery_transaction_io("inspect recovery mount tree entry", source) + })?; + if recovery_mount_id(&path, &relative)? != root_mount_id { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support mounted filesystem entry {}", + relative.display() + ))); + } + if metadata.file_type().is_dir() { + pending.push(relative); + } + } + } + if recovery_mount_id(root, Path::new("."))? != root_mount_id { + return Err(recovery_transaction_blocked( + "recovery directory mount changed while it was checked", + )); + } + Ok(()) +} + #[cfg(target_os = "linux")] fn recovery_inode_flags( path: &Path, @@ -2402,6 +2500,9 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { Err(_) if !backup.path.exists() => {} Err(error) => return Err(error), } + if backup.path.exists() { + ensure_recovery_mount_tree_isolated(&backup.path)?; + } if let Err(source) = fs::remove_dir_all(&backup.path) && source.kind() != std::io::ErrorKind::NotFound { @@ -2449,6 +2550,7 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> } }; let result = (|| { + let _mount_id = recovery_mount_id(&source, Path::new("capability probe"))?; let output = Command::new("unshare") .args([ "--user", @@ -5067,6 +5169,159 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data() + -> Result<(), Box> { + const CHILD: &str = "BITBYGIT_TEST_NESTED_RECOVERY_MOUNT"; + + if env::var_os(CHILD).is_none() { + let probe_root = TempRepo::new()?; + let probe_source = probe_root.path().join("source"); + let probe_target = probe_root.path().join("target"); + fs::create_dir(&probe_source)?; + fs::create_dir(&probe_target)?; + let probe = Command::new("unshare") + .args([ + "--user", + "--map-root-user", + "--mount", + "--fork", + "mount", + "--bind", + ]) + .arg(&probe_source) + .arg(&probe_target) + .output()?; + if !probe.status.success() { + if env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { + return Err(format!( + "nested-mount recovery capabilities are required for this test: {}", + String::from_utf8_lossy(&probe.stderr).trim() + ) + .into()); + } + return Ok(()); + } + let output = Command::new("unshare") + .args(["--user", "--map-root-user", "--mount", "--fork"]) + .arg(env::current_exe()?) + .args([ + "--exact", + "tests::exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data", + "--nocapture", + ]) + .env(CHILD, "1") + .output()?; + assert!( + output.status.success(), + "nested-mount recovery child failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return Ok(()); + } + + use std::os::unix::fs::MetadataExt; + + struct BindMountGuard(Option); + + impl BindMountGuard { + fn unmount(&mut self) -> Result<(), Box> { + let Some(target) = self.0.take() else { + return Ok(()); + }; + let status = Command::new("umount").arg(&target).status()?; + if !status.success() { + self.0 = Some(target); + return Err("nested bind unmount failed".into()); + } + Ok(()) + } + } + + impl Drop for BindMountGuard { + fn drop(&mut self) { + if let Some(target) = &self.0 { + let _result = Command::new("umount").arg(target).status(); + } + } + } + + let (repo, _original_head) = prepare_merge_conflict()?; + let source = repo.path().with_extension("nested-mount-source"); + let target = repo.path().join("nested-mount"); + fs::create_dir(&source)?; + fs::create_dir(&target)?; + fs::write(source.join("sentinel"), "must survive recovery\n")?; + assert_eq!(fs::metadata(&source)?.dev(), fs::metadata(&target)?.dev()); + let bind_mount = |target: &Path| -> Result> { + let mount = Command::new("mount") + .args(["--bind"]) + .arg(&source) + .arg(target) + .output()?; + if !mount.status.success() { + return Err(format!( + "nested bind mount failed: {}", + String::from_utf8_lossy(&mount.stderr).trim() + ) + .into()); + } + Ok(BindMountGuard(Some(target.to_owned()))) + }; + let mut guard = bind_mount(&target)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + + for attempt in 1..=2 { + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err( + format!("nested mount recovery attempt {attempt} was not blocked").into(), + ); + }; + assert!( + error.to_string().contains("mounted filesystem entry"), + "{error}" + ); + assert_eq!(git.recovery_state()?, expected); + assert_eq!( + fs::read_to_string(source.join("sentinel"))?, + "must survive recovery\n" + ); + } + + guard.unmount()?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let root = repo.path().canonicalize()?; + let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; + repo.run_allow_failure(["merge", "other"])?; + let expected = git.recovery_state()?; + let mut guard = bind_mount(&backup.join("nested-mount"))?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("nested backup mount was not blocked before later recovery cleanup".into()); + }; + assert!( + error.to_string().contains("mounted filesystem entry"), + "{error}" + ); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup)); + assert_eq!( + fs::read_to_string(source.join("sentinel"))?, + "must survive recovery\n" + ); + + guard.unmount()?; + fs::remove_dir_all(source)?; + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_rejects_symlinked_mutable_git_storage() -> Result<(), Box> { From 039e95063f23cb4bda9d7153b5ea8410802e4509 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 12:25:45 -0400 Subject: [PATCH 22/53] fix: close recovery promotion and cleanup races --- crates/bitbygit-git/src/lib.rs | 638 +++++++++++++++++++++++++++++++-- 1 file changed, 606 insertions(+), 32 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 0f8fa86..ebb29d3 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -2,6 +2,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::error::Error; use std::ffi::OsString; +#[cfg(target_os = "linux")] +use std::ffi::{CStr, CString}; use std::fmt::{self, Display, Formatter}; use std::fs; use std::io::{BufReader, Read, Write}; @@ -1513,6 +1515,8 @@ struct RecoveryTransaction { candidate: PathBuf, backup_pointer: PathBuf, baseline: RecoveryGeneration, + root_identity: (u64, u64), + candidate_identity: (u64, u64), keep_candidate: bool, } @@ -1524,7 +1528,15 @@ impl RecoveryTransaction { })?; remove_previous_recovery_backup(&root)?; let baseline = RecoveryGeneration::capture(&root)?; + let root_identity = + recovery_file_identity(&fs::symlink_metadata(&root).map_err(|source| { + recovery_transaction_io("identify repository generation", source) + })?); let (candidate, backup_identity) = create_recovery_candidate(parent, &root)?; + let candidate_identity = + recovery_file_identity(&fs::symlink_metadata(&candidate).map_err(|source| { + recovery_transaction_io("identify isolated repository", source) + })?); let backup_pointer = recovery_backup_pointer_path(&candidate)?; let mut pointer_contents = candidate.as_os_str().as_encoded_bytes().to_vec(); pointer_contents.push(0); @@ -1535,6 +1547,8 @@ impl RecoveryTransaction { candidate, backup_pointer, baseline, + root_identity, + candidate_identity, keep_candidate: false, }; transaction.copy_repository()?; @@ -1703,6 +1717,7 @@ impl RecoveryTransaction { ) -> Result { if live_git.recovery_state()? != *expected_state || RecoveryGeneration::capture(&self.root)? != self.baseline + || recovery_path_identity(&self.root)? != self.root_identity { return Err(recovery_transaction_blocked( "repository changed while recovery executed in isolation", @@ -1710,23 +1725,40 @@ impl RecoveryTransaction { } run_recovery_promotion_hook(&self.root); + ensure_recovery_git_storage_isolated(&self.candidate)?; + let candidate_generation = RecoveryGeneration::capture_isolated(&self.candidate)?; + if recovery_path_identity(&self.candidate)? != self.candidate_identity { + return Err(recovery_transaction_blocked( + "isolated recovery candidate changed before atomic promotion", + )); + } ensure_recovery_mount_tree_isolated(&self.root)?; atomic_exchange_directories(&self.root, &self.candidate)?; self.keep_candidate = true; let old_generation = RecoveryGeneration::capture(&self.candidate); - if !matches!(&old_generation, Ok(generation) if generation == &self.baseline) { + let installed_generation = RecoveryGeneration::capture_isolated(&self.root); + let identities_match = recovery_path_identity(&self.root) + .is_ok_and(|identity| identity == self.candidate_identity) + && recovery_path_identity(&self.candidate) + .is_ok_and(|identity| identity == self.root_identity); + if !identities_match + || !matches!(&old_generation, Ok(generation) if generation == &self.baseline) + || !matches!(&installed_generation, Ok(generation) if generation == &candidate_generation) + { if let Err(error) = atomic_exchange_directories(&self.root, &self.candidate) { return Err(recovery_transaction_blocked(format!( "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" ))); } self.keep_candidate = false; - return Err(recovery_transaction_blocked(match old_generation { - Ok(_) => "repository changed during atomic recovery promotion".to_owned(), - Err(error) => { - format!("repository metadata changed during atomic recovery promotion: {error}") - } - })); + return Err(recovery_transaction_blocked( + match (old_generation, installed_generation) { + (Err(error), _) | (_, Err(error)) => format!( + "repository metadata changed during atomic recovery promotion: {error}" + ), + _ => "repository changed during atomic recovery promotion".to_owned(), + }, + )); } Ok(self.candidate.clone()) } @@ -1734,10 +1766,10 @@ impl RecoveryTransaction { impl Drop for RecoveryTransaction { fn drop(&mut self) { - if !self.keep_candidate && ensure_recovery_mount_tree_isolated(&self.candidate).is_ok() { - let _result = fs::remove_dir_all(&self.candidate); + if !self.keep_candidate + && remove_recovery_directory(&self.candidate, self.candidate_identity).is_ok() + { let _result = fs::remove_file(recovery_candidate_owner_path(&self.candidate)); - let _result = fs::remove_file(&self.backup_pointer); } } } @@ -2146,6 +2178,231 @@ fn ensure_recovery_mount_tree_isolated(root: &Path) -> Result<(), GitError> { Ok(()) } +#[cfg(target_os = "linux")] +fn remove_recovery_directory(path: &Path, expected_identity: (u64, u64)) -> Result<(), GitError> { + use rustix::fs::{CWD, Mode, OFlags, openat}; + + let parent = path.parent().ok_or_else(|| { + recovery_transaction_blocked("recovery cleanup path has no parent directory") + })?; + let name = path.file_name().ok_or_else(|| { + recovery_transaction_blocked("recovery cleanup path has no directory name") + })?; + let name = CString::new(name.as_encoded_bytes()).map_err(|_| { + recovery_transaction_blocked("recovery cleanup path contains an invalid directory name") + })?; + let parent_fd = openat( + CWD, + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| recovery_cleanup_error("open recovery cleanup parent", source))?; + let root_fd = match openat( + &parent_fd, + name.as_c_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(root_fd) => root_fd, + Err(rustix::io::Errno::NOENT) => return Ok(()), + Err(source) => { + return Err(recovery_cleanup_error( + "open recovery cleanup directory", + source, + )); + } + }; + if recovery_fd_identity(&root_fd)? != expected_identity { + return Err(recovery_transaction_blocked( + "recovery cleanup directory identity changed; refusing to remove it", + )); + } + let root_mount_id = recovery_fd_mount_id(&root_fd)?; + run_recovery_cleanup_hook(path); + let mut entries = 0_usize; + let mut path_bytes = 0_usize; + remove_recovery_directory_contents(&root_fd, root_mount_id, &mut entries, &mut path_bytes)?; + ensure_recovery_cleanup_entry( + &parent_fd, + name.as_c_str(), + expected_identity, + root_mount_id, + )?; + rustix::fs::unlinkat(&parent_fd, name.as_c_str(), rustix::fs::AtFlags::REMOVEDIR) + .map_err(|source| recovery_cleanup_error("remove recovery cleanup directory", source))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn remove_recovery_directory_contents( + directory: &impl std::os::fd::AsFd, + root_mount_id: u64, + entries: &mut usize, + path_bytes: &mut usize, +) -> Result<(), GitError> { + use rustix::fs::{FileType, Mode, OFlags, openat}; + + let names = rustix::fs::Dir::read_from(directory) + .map_err(|source| recovery_cleanup_error("read recovery cleanup directory", source))? + .filter_map(|entry| match entry { + Ok(entry) if entry.file_name().to_bytes() == b"." => None, + Ok(entry) if entry.file_name().to_bytes() == b".." => None, + Ok(entry) => Some(Ok(entry.file_name().to_owned())), + Err(source) => Some(Err(source)), + }) + .collect::, _>>() + .map_err(|source| recovery_cleanup_error("read recovery cleanup entry", source))?; + for name in names { + *entries = entries.saturating_add(1); + *path_bytes = path_bytes.saturating_add(name.as_bytes().len()); + if *entries > MAX_RECOVERY_GENERATION_ENTRIES + || *path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_transaction_blocked( + "recovery cleanup exceeds atomic recovery entry or path bounds", + )); + } + let entry_fd = match openat( + directory, + name.as_c_str(), + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(entry_fd) => entry_fd, + Err(rustix::io::Errno::NOENT) => continue, + Err(source) => { + return Err(recovery_cleanup_error( + "open recovery cleanup entry", + source, + )); + } + }; + let identity = recovery_fd_identity(&entry_fd)?; + let mount_id = recovery_fd_mount_id(&entry_fd)?; + if mount_id != root_mount_id { + return Err(recovery_transaction_blocked(format!( + "atomic recovery cleanup refuses mounted filesystem entry {}", + name.to_string_lossy() + ))); + } + let file_type = FileType::from_raw_mode( + rustix::fs::fstat(&entry_fd) + .map_err(|source| recovery_cleanup_error("inspect recovery cleanup entry", source))? + .st_mode, + ); + if file_type == FileType::Directory { + let child = openat( + directory, + name.as_c_str(), + OFlags::RDONLY + | OFlags::DIRECTORY + | OFlags::NOFOLLOW + | OFlags::NONBLOCK + | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| recovery_cleanup_error("open recovery cleanup child", source))?; + if recovery_fd_identity(&child)? != identity + || recovery_fd_mount_id(&child)? != root_mount_id + { + return Err(recovery_transaction_blocked( + "recovery cleanup entry changed while it was opened", + )); + } + remove_recovery_directory_contents(&child, root_mount_id, entries, path_bytes)?; + ensure_recovery_cleanup_entry(directory, name.as_c_str(), identity, root_mount_id)?; + rustix::fs::unlinkat(directory, name.as_c_str(), rustix::fs::AtFlags::REMOVEDIR) + .map_err(|source| { + recovery_cleanup_error("remove recovery cleanup child", source) + })?; + } else { + ensure_recovery_cleanup_entry(directory, name.as_c_str(), identity, root_mount_id)?; + rustix::fs::unlinkat(directory, name.as_c_str(), rustix::fs::AtFlags::empty()) + .map_err(|source| { + recovery_cleanup_error("remove recovery cleanup entry", source) + })?; + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_recovery_cleanup_entry( + parent: &impl std::os::fd::AsFd, + name: &CStr, + expected_identity: (u64, u64), + root_mount_id: u64, +) -> Result<(), GitError> { + use rustix::fs::{Mode, OFlags, openat}; + + let entry = openat( + parent, + name, + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| recovery_cleanup_error("recheck recovery cleanup entry", source))?; + if recovery_fd_identity(&entry)? != expected_identity { + return Err(recovery_transaction_blocked(format!( + "recovery cleanup entry {} changed before removal", + name.to_string_lossy() + ))); + } + if recovery_fd_mount_id(&entry)? != root_mount_id { + return Err(recovery_transaction_blocked(format!( + "atomic recovery cleanup refuses mounted filesystem entry {}", + name.to_string_lossy() + ))); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn recovery_fd_identity(fd: &impl std::os::fd::AsFd) -> Result<(u64, u64), GitError> { + let metadata = rustix::fs::fstat(fd) + .map_err(|source| recovery_cleanup_error("identify opened recovery entry", source))?; + Ok((metadata.st_dev, metadata.st_ino)) +} + +#[cfg(target_os = "linux")] +fn recovery_fd_mount_id(fd: &impl std::os::fd::AsFd) -> Result { + use rustix::fs::{AtFlags, StatxFlags, statx}; + + let result = statx( + fd, + c"", + AtFlags::EMPTY_PATH | AtFlags::NO_AUTOMOUNT | AtFlags::SYMLINK_NOFOLLOW, + StatxFlags::MNT_ID, + ) + .map_err(|source| { + recovery_capability_unavailable(format!( + "an opened recovery entry mount identity could not be inspected: {source}" + )) + })?; + if result.stx_mask & StatxFlags::MNT_ID.bits() == 0 { + return Err(recovery_capability_unavailable( + "Linux mount identity inspection is not available", + )); + } + Ok(result.stx_mnt_id) +} + +#[cfg(target_os = "linux")] +fn recovery_cleanup_error(action: &str, source: rustix::io::Errno) -> GitError { + recovery_transaction_io( + action, + std::io::Error::from_raw_os_error(source.raw_os_error()), + ) +} + +#[cfg(not(target_os = "linux"))] +fn remove_recovery_directory(_path: &Path, _expected_identity: (u64, u64)) -> Result<(), GitError> { + Err(recovery_transaction_blocked( + "safe recovery cleanup is not supported on this platform", + )) +} + #[cfg(target_os = "linux")] fn recovery_inode_flags( path: &Path, @@ -2367,6 +2624,7 @@ fn recovery_backup_pointer_path(root: &Path) -> Result { struct RecoveryBackup { path: PathBuf, + identity: (u64, u64), generation_digest: [u8; RECOVERY_GENERATION_DIGEST_BYTES], } @@ -2443,6 +2701,18 @@ fn recovery_backup_record_from_pointer( digest.copy_from_slice(generation_digest); Ok(Some(RecoveryBackup { path: backup, + identity: ( + u64::from_le_bytes( + identity[..8] + .try_into() + .map_err(|_| invalid_recovery_backup_pointer())?, + ), + u64::from_le_bytes( + identity[8..16] + .try_into() + .map_err(|_| invalid_recovery_backup_pointer())?, + ), + ), generation_digest: digest, })) } @@ -2500,17 +2770,7 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { Err(_) if !backup.path.exists() => {} Err(error) => return Err(error), } - if backup.path.exists() { - ensure_recovery_mount_tree_isolated(&backup.path)?; - } - if let Err(source) = fs::remove_dir_all(&backup.path) - && source.kind() != std::io::ErrorKind::NotFound - { - return Err(recovery_transaction_io( - "remove the previous retained recovery backup", - source, - )); - } + remove_recovery_directory(&backup.path, backup.identity)?; fs::remove_file(recovery_backup_pointer_path(root)?) .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; let _result = fs::remove_file(recovery_candidate_owner_path(&backup.path)); @@ -2692,6 +2952,23 @@ fn run_recovery_promotion_hook(root: &Path) { #[cfg(not(test))] fn run_recovery_promotion_hook(_root: &Path) {} +#[cfg(test)] +static RECOVERY_CLEANUP_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_cleanup_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_CLEANUP_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_cleanup_hook(_root: &Path) {} + #[cfg(test)] static RECOVERY_SIDECAR_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -2847,13 +3124,26 @@ fn run_bounded_recovery_command( args: args.clone(), source: std::io::Error::other("recovery command could not capture stderr"), })?; + #[cfg(target_os = "linux")] + { + if let Err(source) = configure_recovery_pipe_nonblocking(&stdout) + .and_then(|()| configure_recovery_pipe_nonblocking(&stderr)) + { + let _ = terminate_recovery_process_group(&mut child); + let _ = child.wait(); + return Err(GitError::Io { args, source }); + } + } let output_bytes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let stop_readers = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let stdout_bytes = std::sync::Arc::clone(&output_bytes); let stderr_bytes = std::sync::Arc::clone(&output_bytes); + let stdout_stop = std::sync::Arc::clone(&stop_readers); + let stderr_stop = std::sync::Arc::clone(&stop_readers); let stdout_reader = - std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes)); + std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes, stdout_stop)); let stderr_reader = - std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes)); + std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes, stderr_stop)); let status = loop { if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { #[cfg(target_os = "linux")] @@ -2861,19 +3151,20 @@ fn run_bounded_recovery_command( #[cfg(not(target_os = "linux"))] let termination = child.kill(); let status = child.wait(); - termination.map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - break status; + break termination.and(status); } match child.try_wait() { Ok(Some(status)) => break Ok(status), Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), Err(source) => break Err(source), } - } - .map_err(|source| GitError::Io { + }; + #[cfg(target_os = "linux")] + let post_exit_termination = terminate_recovery_process_group(&mut child); + #[cfg(not(target_os = "linux"))] + let post_exit_termination = Ok(()); + stop_readers.store(true, std::sync::atomic::Ordering::Release); + let status = status.map_err(|source| GitError::Io { args: args.clone(), source, })?; @@ -2887,6 +3178,10 @@ fn run_bounded_recovery_command( args: args.clone(), source, })?; + post_exit_termination.map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; let stderr = stderr_reader .join() .map_err(|_| GitError::Io { @@ -2928,11 +3223,23 @@ fn terminate_recovery_process_group(child: &mut std::process::Child) -> std::io: fn read_bounded_recovery_output( mut stream: impl Read, total: std::sync::Arc, + stop: std::sync::Arc, ) -> std::io::Result> { let mut retained = Vec::new(); let mut buffer = [0_u8; 16 * 1024]; loop { - let read = stream.read(&mut buffer)?; + let read = match stream.read(&mut buffer) { + Ok(read) => read, + Err(source) if source.kind() == std::io::ErrorKind::Interrupted => continue, + Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => { + if stop.load(std::sync::atomic::Ordering::Acquire) { + return Ok(retained); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + Err(source) => return Err(source), + }; if read == 0 { return Ok(retained); } @@ -2947,6 +3254,14 @@ fn read_bounded_recovery_output( } } +#[cfg(target_os = "linux")] +fn configure_recovery_pipe_nonblocking(pipe: &impl std::os::fd::AsFd) -> std::io::Result<()> { + let flags = rustix::fs::fcntl_getfl(pipe) + .map_err(|source| std::io::Error::from_raw_os_error(source.raw_os_error()))?; + rustix::fs::fcntl_setfl(pipe, flags | rustix::fs::OFlags::NONBLOCK) + .map_err(|source| std::io::Error::from_raw_os_error(source.raw_os_error())) +} + fn recovery_bound_error(detail: String) -> GitError { GitError::Blocked { message: format!("recovery is blocked because the recovery guard {detail}"), @@ -3022,6 +3337,17 @@ fn recovery_file_identity(metadata: &fs::Metadata) -> (u64, u64) { (metadata.dev(), metadata.ino()) } +fn recovery_path_identity(path: &Path) -> Result<(u64, u64), GitError> { + let metadata = fs::symlink_metadata(path) + .map_err(|source| recovery_transaction_io("identify recovery directory", source))?; + if !metadata.file_type().is_dir() { + return Err(recovery_transaction_blocked( + "recovery directory changed before its identity could be verified", + )); + } + Ok(recovery_file_identity(&metadata)) +} + #[cfg(not(unix))] fn recovery_file_identity(_metadata: &fs::Metadata) -> (u64, u64) { (0, 0) @@ -4754,13 +5080,17 @@ mod tests { let root = clone.path().canonicalize()?; let baseline = RecoveryGeneration::capture(&root)?; + let root_identity = recovery_path_identity(&root)?; let parent = root.parent().ok_or("local clone has no parent")?; let (candidate, _) = create_recovery_candidate(parent, &root)?; + let candidate_identity = recovery_path_identity(&candidate)?; let mut transaction = RecoveryTransaction { root: root.clone(), backup_pointer: recovery_backup_pointer_path(&candidate)?, candidate, baseline, + root_identity, + candidate_identity, keep_candidate: false, }; transaction.copy_repository()?; @@ -4980,6 +5310,69 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_terminates_quiet_hook_descendants_after_git_exits() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + let hook_pid = repo.path().with_extension("quiet-hook-pid"); + let late_side_effect = repo.path().with_extension("quiet-hook-late-effect"); + fs::write( + &hook, + format!( + "#!/bin/sh\n(sh -c 'printf \"%s\\n\" \"$$\" > {}; sleep 30; touch {}') &\nwhile [ ! -s {} ]; do sleep 0.01; done\n", + shell_quote(&hook_pid.to_string_lossy()), + shell_quote(&late_side_effect.to_string_lossy()), + shell_quote(&hook_pid.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + + let started = std::time::Instant::now(); + git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )?; + + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "recovery waited for a quiet hook descendant holding its output pipe" + ); + let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; + let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid quiet hook pid")?; + for _ in 0..200 { + if rustix::process::test_kill_process(pid).is_err() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + rustix::process::test_kill_process(pid).is_err(), + "quiet recovery hook descendant survived post-exit termination" + ); + assert!(!late_side_effect.exists()); + assert_eq!(git.status()?.operation, None); + fs::remove_file(hook_pid)?; + Ok(()) + } + #[test] fn changed_retained_backup_blocks_later_recovery() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -5299,13 +5692,27 @@ mod tests { let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; repo.run_allow_failure(["merge", "other"])?; let expected = git.recovery_state()?; - let mut guard = bind_mount(&backup.join("nested-mount"))?; + let late_mount_target = backup.join("nested-mount"); + let hook_source = source.clone(); + let hook_target = late_mount_target.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _result = Command::new("mount") + .args(["--bind"]) + .arg(hook_source) + .arg(hook_target) + .status(); + }); + RECOVERY_CLEANUP_HOOKS + .lock() + .map_err(|_| "recovery cleanup hook lock poisoned")? + .push((backup.clone(), hook)); let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { return Err("nested backup mount was not blocked before later recovery cleanup".into()); }; + let mut guard = BindMountGuard(Some(late_mount_target)); assert!( error.to_string().contains("mounted filesystem entry"), "{error}" @@ -5674,6 +6081,173 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_hook_created_special_entry_before_promotion() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write(&hook, "#!/bin/sh\nmkfifo hook-created-special-entry\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected hook-created special entry to block promotion".into()); + }; + + assert!( + error.to_string().contains("special filesystem entry"), + "{error}" + ); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert!(!repo.path().join("hook-created-special-entry").exists()); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_rejects_replaced_candidate_identity() -> Result<(), Box> { + use std::sync::{Arc, Mutex}; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let parent = root + .parent() + .ok_or("test repository has no parent")? + .to_owned(); + let root_identity = recovery_path_identity(&root)?; + let replaced = Arc::new(Mutex::new(None)); + let hook_replaced = Arc::clone(&replaced); + let hook: RecoveryCaptureHook = Box::new(move || { + let Ok(entries) = fs::read_dir(&parent) else { + return; + }; + for entry in entries.flatten() { + let candidate = entry.path(); + let owner = recovery_candidate_owner_path(&candidate); + let mut expected_owner = Vec::with_capacity(16); + expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); + expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); + if !fs::read(&owner).is_ok_and(|bytes| bytes.starts_with(&expected_owner)) { + continue; + } + let displaced = candidate.with_extension("displaced"); + if fs::rename(&candidate, &displaced).is_err() + || fs::create_dir(&candidate).is_err() + || fs::create_dir(candidate.join(".git")).is_err() + || fs::write(candidate.join("replacement-sentinel"), "preserve\n").is_err() + { + return; + } + if let Ok(mut paths) = hook_replaced.lock() { + *paths = Some((candidate, displaced, owner)); + } + return; + } + }); + RECOVERY_PROMOTION_HOOKS + .lock() + .map_err(|_| "recovery promotion hook lock poisoned")? + .push((root, hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected replaced recovery candidate to block promotion".into()); + }; + + assert!(error.to_string().contains("candidate changed"), "{error}"); + assert_eq!(git.recovery_state()?, expected); + let (candidate, displaced, owner) = replaced + .lock() + .map_err(|_| "replaced candidate path lock poisoned")? + .take() + .ok_or("promotion hook did not replace the candidate")?; + assert_eq!( + fs::read_to_string(candidate.join("replacement-sentinel"))?, + "preserve\n" + ); + fs::remove_dir_all(candidate)?; + fs::remove_dir_all(displaced)?; + let _ = fs::remove_file(owner); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_cleanup_rejects_synchronized_path_replacement() -> Result<(), Box> { + let repo = initialized_repo()?; + let root = repo.path().canonicalize()?; + let parent = root.parent().ok_or("test repository has no parent")?; + let (candidate, _) = create_recovery_candidate(parent, &root)?; + fs::write( + candidate.join("owned-data"), + "remove only this generation\n", + )?; + let identity = recovery_path_identity(&candidate)?; + let displaced = candidate.with_extension("cleanup-displaced"); + let hook_candidate = candidate.clone(); + let hook_displaced = displaced.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + if fs::rename(&hook_candidate, &hook_displaced).is_ok() + && fs::create_dir(&hook_candidate).is_ok() + { + let _ = fs::write(hook_candidate.join("replacement-sentinel"), "preserve\n"); + } + }); + RECOVERY_CLEANUP_HOOKS + .lock() + .map_err(|_| "recovery cleanup hook lock poisoned")? + .push((candidate.clone(), hook)); + + let Err(error) = remove_recovery_directory(&candidate, identity) else { + return Err("expected synchronized cleanup replacement to be rejected".into()); + }; + + assert!( + error.to_string().contains("changed before removal"), + "{error}" + ); + assert_eq!( + fs::read_to_string(candidate.join("replacement-sentinel"))?, + "preserve\n" + ); + fs::remove_dir_all(&candidate)?; + fs::remove_dir_all(&displaced)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn atomic_promotion_rolls_back_concurrent_xattr_change() -> Result<(), Box> { From 0d53d75d4d9ecf31015df6cd0f33a04347ec1dc1 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 12:47:31 -0400 Subject: [PATCH 23/53] fix: bound recovery commands and directory reads --- crates/bitbygit-git/src/lib.rs | 280 ++++++++++++++++++++++++++++----- 1 file changed, 244 insertions(+), 36 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index ebb29d3..95573be 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -46,6 +46,7 @@ const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; const MAX_RECOVERY_SUBPROCESSES: usize = 12; +const MAX_RECOVERY_COMMAND_DURATION: std::time::Duration = std::time::Duration::from_secs(30); const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; @@ -1670,7 +1671,11 @@ impl RecoveryTransaction { .unwrap_or_else(|| "ssh".to_owned()); command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); } - let output = run_bounded_recovery_command(&mut command, args.clone())?; + let output = run_bounded_recovery_command( + &mut command, + args.clone(), + recovery_command_duration(&self.root), + )?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); if !output.status.success() { @@ -1815,22 +1820,15 @@ impl RecoveryGeneration { let mut file_bytes = 0_u64; while let Some(relative_dir) = pending.pop() { let directory = root.join(&relative_dir); - let mut children = fs::read_dir(&directory) - .map_err(|source| recovery_transaction_io("read repository generation", source))? - .collect::, _>>() - .map_err(|source| recovery_transaction_io("read repository generation", source))?; - children.sort_by_key(|entry| entry.file_name()); + let children = read_recovery_generation_children( + &directory, + &relative_dir, + entries.len(), + &mut path_bytes, + MAX_RECOVERY_GENERATION_ENTRIES, + )?; for child in children.into_iter().rev() { let relative = relative_dir.join(child.file_name()); - path_bytes = - path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); - if entries.len() == MAX_RECOVERY_GENERATION_ENTRIES - || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_transaction_blocked( - "repository generation exceeds atomic recovery entry or path bounds", - )); - } let path = child.path(); let metadata = fs::symlink_metadata(&path).map_err(|source| { recovery_transaction_io("inspect repository generation entry", source) @@ -2003,6 +2001,36 @@ impl RecoveryGeneration { } } +fn read_recovery_generation_children( + directory: &Path, + relative_dir: &Path, + captured_entries: usize, + path_bytes: &mut usize, + entry_limit: usize, +) -> Result, GitError> { + let mut children = Vec::new(); + for child in fs::read_dir(directory) + .map_err(|source| recovery_transaction_io("read repository generation", source))? + { + let child = child + .map_err(|source| recovery_transaction_io("read repository generation", source))?; + let relative = relative_dir.join(child.file_name()); + let next_path_bytes = + path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); + if captured_entries.saturating_add(children.len()) >= entry_limit + || next_path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_transaction_blocked( + "repository generation exceeds atomic recovery entry or path bounds", + )); + } + *path_bytes = next_path_bytes; + children.push(child); + } + children.sort_by_key(|entry| entry.file_name()); + Ok(children) +} + #[derive(Debug, PartialEq, Eq)] struct RecoveryGenerationEntry { path: PathBuf, @@ -2243,26 +2271,13 @@ fn remove_recovery_directory_contents( ) -> Result<(), GitError> { use rustix::fs::{FileType, Mode, OFlags, openat}; - let names = rustix::fs::Dir::read_from(directory) - .map_err(|source| recovery_cleanup_error("read recovery cleanup directory", source))? - .filter_map(|entry| match entry { - Ok(entry) if entry.file_name().to_bytes() == b"." => None, - Ok(entry) if entry.file_name().to_bytes() == b".." => None, - Ok(entry) => Some(Ok(entry.file_name().to_owned())), - Err(source) => Some(Err(source)), - }) - .collect::, _>>() - .map_err(|source| recovery_cleanup_error("read recovery cleanup entry", source))?; + let names = read_recovery_cleanup_names( + directory, + entries, + path_bytes, + MAX_RECOVERY_GENERATION_ENTRIES, + )?; for name in names { - *entries = entries.saturating_add(1); - *path_bytes = path_bytes.saturating_add(name.as_bytes().len()); - if *entries > MAX_RECOVERY_GENERATION_ENTRIES - || *path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_transaction_blocked( - "recovery cleanup exceeds atomic recovery entry or path bounds", - )); - } let entry_fd = match openat( directory, name.as_c_str(), @@ -2327,6 +2342,36 @@ fn remove_recovery_directory_contents( Ok(()) } +#[cfg(target_os = "linux")] +fn read_recovery_cleanup_names( + directory: &impl std::os::fd::AsFd, + entries: &mut usize, + path_bytes: &mut usize, + entry_limit: usize, +) -> Result, GitError> { + let mut names = Vec::new(); + for entry in rustix::fs::Dir::read_from(directory) + .map_err(|source| recovery_cleanup_error("read recovery cleanup directory", source))? + { + let entry = entry + .map_err(|source| recovery_cleanup_error("read recovery cleanup entry", source))?; + if matches!(entry.file_name().to_bytes(), b"." | b"..") { + continue; + } + let name = entry.file_name().to_owned(); + let next_path_bytes = path_bytes.saturating_add(name.as_bytes().len()); + if *entries >= entry_limit || next_path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES { + return Err(recovery_transaction_blocked( + "recovery cleanup exceeds atomic recovery entry or path bounds", + )); + } + *entries += 1; + *path_bytes = next_path_bytes; + names.push(name); + } + Ok(names) +} + #[cfg(target_os = "linux")] fn ensure_recovery_cleanup_entry( parent: &impl std::os::fd::AsFd, @@ -2986,6 +3031,25 @@ fn run_recovery_sidecar_hook(path: &Path) { #[cfg(not(test))] fn run_recovery_sidecar_hook(_path: &Path) {} +#[cfg(test)] +static RECOVERY_COMMAND_DURATIONS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_command_duration(root: &Path) -> std::time::Duration { + if let Ok(mut durations) = RECOVERY_COMMAND_DURATIONS.lock() + && let Some(index) = durations.iter().position(|(target, _)| target == root) + { + return durations.swap_remove(index).1; + } + MAX_RECOVERY_COMMAND_DURATION +} + +#[cfg(not(test))] +fn recovery_command_duration(_root: &Path) -> std::time::Duration { + MAX_RECOVERY_COMMAND_DURATION +} + #[derive(Default)] struct RecoveryCapture { output_bytes: usize, @@ -3039,7 +3103,11 @@ impl RecoveryCapture { .env("SSH_ASKPASS", "") .env("SSH_ASKPASS_REQUIRE", "never") .args(&args); - let output = run_bounded_recovery_command(&mut command, display_args.clone())?; + let output = run_bounded_recovery_command( + &mut command, + display_args.clone(), + MAX_RECOVERY_COMMAND_DURATION, + )?; let bytes = output.stdout.len().saturating_add(output.stderr.len()); self.output_bytes = self.output_bytes.saturating_add(bytes); if self.output_bytes > MAX_RECOVERY_OUTPUT_BYTES { @@ -3105,13 +3173,17 @@ impl RecoveryCapture { fn run_bounded_recovery_command( command: &mut Command, args: Vec, + duration: std::time::Duration, ) -> Result { #[cfg(target_os = "linux")] { use std::os::unix::process::CommandExt; command.process_group(0); } - command.stdout(Stdio::piped()).stderr(Stdio::piped()); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|source| GitError::Io { args: args.clone(), source, @@ -3144,6 +3216,8 @@ fn run_bounded_recovery_command( std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes, stdout_stop)); let stderr_reader = std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes, stderr_stop)); + let started = std::time::Instant::now(); + let mut deadline_exceeded = false; let status = loop { if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { #[cfg(target_os = "linux")] @@ -3153,6 +3227,15 @@ fn run_bounded_recovery_command( let status = child.wait(); break termination.and(status); } + if started.elapsed() >= duration { + deadline_exceeded = true; + #[cfg(target_os = "linux")] + let termination = terminate_recovery_process_group(&mut child); + #[cfg(not(target_os = "linux"))] + let termination = child.kill(); + let status = child.wait(); + break termination.and(status); + } match child.try_wait() { Ok(Some(status)) => break Ok(status), Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), @@ -3197,6 +3280,12 @@ fn run_bounded_recovery_command( "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" ))); } + if deadline_exceeded { + return Err(recovery_bound_error(format!( + "Git command exceeded its {}ms execution deadline", + duration.as_millis() + ))); + } Ok(RawProcessOutput { args, status, @@ -5310,6 +5399,78 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_deadline_terminates_quiet_hook_without_promoting() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + let hook_pid = repo.path().with_extension("hanging-hook-pid"); + let late_side_effect = repo.path().with_extension("hanging-hook-late-effect"); + fs::write( + &hook, + format!( + "#!/bin/sh\nprintf '%s\\n' $$ > {}\nsleep 30\ntouch {}\n", + shell_quote(&hook_pid.to_string_lossy()), + shell_quote(&late_side_effect.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + RECOVERY_COMMAND_DURATIONS + .lock() + .map_err(|_| "recovery command duration lock poisoned")? + .push((root.clone(), std::time::Duration::from_secs(1))); + + let started = std::time::Instant::now(); + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected quiet recovery hook to exceed the execution deadline".into()); + }; + + assert!(error.to_string().contains("execution deadline"), "{error}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "recovery waited indefinitely for a quiet hook" + ); + let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; + let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid hanging hook pid")?; + for _ in 0..200 { + if rustix::process::test_kill_process(pid).is_err() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + rustix::process::test_kill_process(pid).is_err(), + "quiet recovery hook survived deadline termination" + ); + assert!(!late_side_effect.exists()); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(recovery_backup_from_pointer(&root)?, None); + fs::remove_file(hook_pid)?; + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn recovery_terminates_quiet_hook_descendants_after_git_exits() -> Result<(), Box> { @@ -6592,6 +6753,53 @@ mod tests { Ok(()) } + #[test] + fn recovery_generation_collection_rejects_an_over_limit_wide_directory() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let wide = repo.path().join("wide"); + fs::create_dir(&wide)?; + for name in ["a", "b", "c"] { + fs::write(wide.join(name), [])?; + } + let mut path_bytes = 0; + + let Err(error) = + read_recovery_generation_children(&wide, Path::new("wide"), 0, &mut path_bytes, 2) + else { + return Err("expected wide generation directory to exceed entry bound".into()); + }; + + assert!(error.to_string().contains("entry or path bounds")); + assert!(path_bytes <= "wide/a".len() + "wide/b".len()); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_cleanup_collection_rejects_an_over_limit_wide_directory() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let wide = repo.path().join("wide"); + fs::create_dir(&wide)?; + for name in ["a", "b", "c"] { + fs::write(wide.join(name), [])?; + } + let directory = fs::File::open(&wide)?; + let mut entries = 0; + let mut path_bytes = 0; + + let Err(error) = read_recovery_cleanup_names(&directory, &mut entries, &mut path_bytes, 2) + else { + return Err("expected wide cleanup directory to exceed entry bound".into()); + }; + + assert!(error.to_string().contains("entry or path bounds")); + assert_eq!(entries, 2); + assert_eq!(path_bytes, 2); + Ok(()) + } + #[test] fn changed_path_parser_stops_at_relevant_file_bound() -> Result<(), Box> { let mut output = Vec::new(); From 8abedc7805d4a5182703f4f5c0f25c8febfea347 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:18:38 -0400 Subject: [PATCH 24/53] fix: close remaining recovery races --- crates/bitbygit-git/src/lib.rs | 828 +++++++++++++++++++++++++++++++-- 1 file changed, 786 insertions(+), 42 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 95573be..4cfc624 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -324,6 +324,11 @@ impl Git { ), }); } + if !expected_state.execution.hooks_are_repository_local() { + return Err(recovery_transaction_blocked( + "atomic recovery requires a repository-local hook directory without symlinked hooks", + )); + } let mut transaction = RecoveryTransaction::prepare(self)?; let result = transaction.run_recovery( self, @@ -331,6 +336,7 @@ impl Git { operation.label().to_owned(), format!("--{}", action.label()), ], + expected_state, ); match result { Ok(mut output) => { @@ -875,6 +881,7 @@ impl Git { run_recovery_capture_hook(&self.cwd); let refs = self.recovery_refs(operation, &metadata, &mut capture)?; capture.retain(refs.len(), "recovery refs")?; + let execution = self.recovery_execution_state(&root, &git_dir, &mut capture)?; Ok(RecoveryState { operation, @@ -886,6 +893,42 @@ impl Git { worktree, metadata, refs, + execution, + }) + } + + fn recovery_execution_state( + &self, + root: &Path, + git_dir: &Path, + capture: &mut RecoveryCapture, + ) -> Result { + let config = capture.required(self, &["config", "--null", "--show-scope", "--list"])?; + capture.retain(config.len(), "effective recovery configuration")?; + let config_files = snapshot_recovery_paths( + [ + (PathBuf::from("config"), git_dir.join("config")), + ( + PathBuf::from("config.worktree"), + git_dir.join("config.worktree"), + ), + ], + capture, + )?; + let hooks_path = path_from_bytes(strip_byte_line_ending(&capture.required( + self, + &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], + )?)); + let hooks_location = match hooks_path.strip_prefix(root) { + Ok(relative) => RecoveryHookLocation::Repository(relative.to_owned()), + Err(_) => RecoveryHookLocation::External(hooks_path.clone()), + }; + let hooks = snapshot_recovery_paths([(PathBuf::from("hooks"), hooks_path)], capture)?; + Ok(RecoveryExecutionState { + config, + config_files, + hooks_location, + hooks, }) } @@ -1527,6 +1570,7 @@ impl RecoveryTransaction { let parent = root.parent().ok_or_else(|| { recovery_transaction_blocked("repository root has no parent for isolated recovery") })?; + run_recovery_prepare_hook(&root); remove_previous_recovery_backup(&root)?; let baseline = RecoveryGeneration::capture(&root)?; let root_identity = @@ -1612,12 +1656,28 @@ impl RecoveryTransaction { fn copy_repository(&mut self) -> Result<(), GitError> { ensure_recovery_mount_tree_isolated(&self.root)?; - let output = Command::new("cp") - .args(["-a", "--no-preserve=links", "--reflink=auto", "--"]) + run_recovery_copy_hook(&self.root); + let mut command = Command::new(recovery_copy_program(&self.root)); + command + .args([ + "-a", + "--one-file-system", + "--no-preserve=links", + "--reflink=auto", + "--", + ]) .arg(self.root.join(".")) - .arg(&self.candidate) - .output() - .map_err(|source| recovery_transaction_io("start isolated repository copy", source))?; + .arg(&self.candidate); + let limits = recovery_copy_limits(&self.root); + let candidate = self.candidate.clone(); + let mut guard = move || ensure_recovery_copy_bounds(&candidate, limits); + let output = run_bounded_recovery_process( + &mut command, + vec!["cp".to_owned(), "repository".to_owned()], + recovery_copy_duration(&self.root), + "repository copy", + Some(&mut guard), + )?; if !output.status.success() { return Err(recovery_transaction_blocked(format!( "isolated repository copy failed: {}", @@ -1638,7 +1698,27 @@ impl RecoveryTransaction { Ok(()) } - fn run_recovery(&self, git: &Git, args: Vec) -> Result { + fn run_recovery( + &self, + git: &Git, + args: Vec, + expected_state: &RecoveryState, + ) -> Result { + let candidate_git = Git::new(&self.candidate); + let mut capture = RecoveryCapture::default(); + let candidate_root = candidate_git.repo_root()?; + let candidate_git_dir = candidate_git.git_path("")?; + let candidate_execution = candidate_git.recovery_execution_state( + &candidate_root, + &candidate_git_dir, + &mut capture, + )?; + let live_state = git.recovery_state()?; + if live_state != *expected_state || candidate_execution != expected_state.execution { + return Err(recovery_transaction_blocked( + "repository state, configuration, or hooks changed while isolated recovery was prepared", + )); + } let mut command = Command::new("unshare"); command .args([ @@ -1740,6 +1820,7 @@ impl RecoveryTransaction { ensure_recovery_mount_tree_isolated(&self.root)?; atomic_exchange_directories(&self.root, &self.candidate)?; self.keep_candidate = true; + run_recovery_post_exchange_hook(&self.root); let old_generation = RecoveryGeneration::capture(&self.candidate); let installed_generation = RecoveryGeneration::capture_isolated(&self.root); let identities_match = recovery_path_identity(&self.root) @@ -1755,10 +1836,22 @@ impl RecoveryTransaction { "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" ))); } - self.keep_candidate = false; + let installed_was_unchanged = matches!( + &installed_generation, + Ok(generation) if generation == &candidate_generation + ); + self.keep_candidate = !installed_was_unchanged; return Err(recovery_transaction_blocked( - match (old_generation, installed_generation) { - (Err(error), _) | (_, Err(error)) => format!( + match ( + old_generation, + installed_generation, + installed_was_unchanged, + ) { + (_, _, false) => format!( + "repository changed during atomic recovery promotion; the post-exchange generation was retained at {}", + self.candidate.display() + ), + (Err(error), _, _) | (_, Err(error), _) => format!( "repository metadata changed during atomic recovery promotion: {error}" ), _ => "repository changed during atomic recovery promotion".to_owned(), @@ -2669,7 +2762,6 @@ fn recovery_backup_pointer_path(root: &Path) -> Result { struct RecoveryBackup { path: PathBuf, - identity: (u64, u64), generation_digest: [u8; RECOVERY_GENERATION_DIGEST_BYTES], } @@ -2746,18 +2838,6 @@ fn recovery_backup_record_from_pointer( digest.copy_from_slice(generation_digest); Ok(Some(RecoveryBackup { path: backup, - identity: ( - u64::from_le_bytes( - identity[..8] - .try_into() - .map_err(|_| invalid_recovery_backup_pointer())?, - ), - u64::from_le_bytes( - identity[8..16] - .try_into() - .map_err(|_| invalid_recovery_backup_pointer())?, - ), - ), generation_digest: digest, })) } @@ -2804,21 +2884,29 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { let Some(backup) = recovery_backup_record_from_pointer(root)? else { return Ok(()); }; - match RecoveryGeneration::capture(&backup.path) { + let generation = match RecoveryGeneration::capture(&backup.path) { Ok(generation) if generation.digest() != backup.generation_digest => { return Err(recovery_transaction_blocked(format!( "the retained recovery backup at {} changed after promotion; refusing to remove it", backup.path.display() ))); } - Ok(_) => {} - Err(_) if !backup.path.exists() => {} + Ok(generation) => Some(generation), + Err(_) if !backup.path.exists() => None, Err(error) => return Err(error), + }; + run_recovery_cleanup_hook(&backup.path); + if let Some(generation) = generation { + let current = RecoveryGeneration::capture(&backup.path)?; + if current != generation || current.digest() != backup.generation_digest { + return Err(recovery_transaction_blocked(format!( + "the retained recovery backup at {} changed after promotion; refusing to remove it", + backup.path.display() + ))); + } } - remove_recovery_directory(&backup.path, backup.identity)?; fs::remove_file(recovery_backup_pointer_path(root)?) .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; - let _result = fs::remove_file(recovery_candidate_owner_path(&backup.path)); Ok(()) } @@ -2839,7 +2927,7 @@ fn append_recovery_backup_notice(output: &mut String, backup: &Path) { output.push('\n'); } output.push_str(&format!( - "bitbygit: previous repository generation retained at {}; it will be removed before the next recovery attempt only if unchanged\n", + "bitbygit: previous repository generation retained at {}; it will remain available for explicit cleanup after a later recovery validates it as unchanged\n", backup.display() )); } @@ -2980,6 +3068,23 @@ fn run_recovery_capability_hook(_root: &Path) -> Result<(), GitError> { Ok(()) } +#[cfg(test)] +static RECOVERY_PREPARE_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_prepare_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_PREPARE_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_prepare_hook(_root: &Path) {} + #[cfg(test)] static RECOVERY_PROMOTION_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -2997,6 +3102,23 @@ fn run_recovery_promotion_hook(root: &Path) { #[cfg(not(test))] fn run_recovery_promotion_hook(_root: &Path) {} +#[cfg(test)] +static RECOVERY_POST_EXCHANGE_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_post_exchange_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_POST_EXCHANGE_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_post_exchange_hook(_root: &Path) {} + #[cfg(test)] static RECOVERY_CLEANUP_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3031,6 +3153,134 @@ fn run_recovery_sidecar_hook(path: &Path) { #[cfg(not(test))] fn run_recovery_sidecar_hook(_path: &Path) {} +type RecoveryCopyLimits = (usize, usize, u64); + +#[cfg(test)] +static RECOVERY_COPY_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_copy_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_COPY_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_copy_hook(_root: &Path) {} + +#[cfg(test)] +static RECOVERY_COPY_PROGRAMS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_copy_program(root: &Path) -> OsString { + if let Ok(mut programs) = RECOVERY_COPY_PROGRAMS.lock() + && let Some(index) = programs.iter().position(|(target, _)| target == root) + { + return programs.swap_remove(index).1; + } + OsString::from("cp") +} + +#[cfg(not(test))] +fn recovery_copy_program(_root: &Path) -> OsString { + OsString::from("cp") +} + +#[cfg(test)] +static RECOVERY_COPY_DURATIONS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_copy_duration(root: &Path) -> std::time::Duration { + if let Ok(mut durations) = RECOVERY_COPY_DURATIONS.lock() + && let Some(index) = durations.iter().position(|(target, _)| target == root) + { + return durations.swap_remove(index).1; + } + MAX_RECOVERY_COMMAND_DURATION +} + +#[cfg(not(test))] +fn recovery_copy_duration(_root: &Path) -> std::time::Duration { + MAX_RECOVERY_COMMAND_DURATION +} + +#[cfg(test)] +static RECOVERY_COPY_LIMIT_OVERRIDES: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_copy_limits(root: &Path) -> RecoveryCopyLimits { + RECOVERY_COPY_LIMIT_OVERRIDES + .lock() + .ok() + .and_then(|limits| { + limits + .iter() + .find(|(target, _)| target == root) + .map(|(_, limits)| *limits) + }) + .unwrap_or(( + MAX_RECOVERY_GENERATION_ENTRIES, + MAX_RECOVERY_GENERATION_PATH_BYTES, + MAX_RECOVERY_GENERATION_FILE_BYTES, + )) +} + +#[cfg(not(test))] +fn recovery_copy_limits(_root: &Path) -> RecoveryCopyLimits { + ( + MAX_RECOVERY_GENERATION_ENTRIES, + MAX_RECOVERY_GENERATION_PATH_BYTES, + MAX_RECOVERY_GENERATION_FILE_BYTES, + ) +} + +fn ensure_recovery_copy_bounds( + root: &Path, + (entry_limit, path_limit, file_limit): RecoveryCopyLimits, +) -> Result<(), GitError> { + let mut pending = vec![PathBuf::new()]; + let mut entries = 0_usize; + let mut path_bytes = 0_usize; + let mut file_bytes = 0_u64; + while let Some(relative_dir) = pending.pop() { + let directory = root.join(&relative_dir); + for child in fs::read_dir(&directory) + .map_err(|source| recovery_transaction_io("inspect repository copy", source))? + { + let child = child + .map_err(|source| recovery_transaction_io("inspect repository copy", source))?; + let relative = relative_dir.join(child.file_name()); + entries = entries.saturating_add(1); + path_bytes = path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); + if entries > entry_limit || path_bytes > path_limit { + return Err(recovery_transaction_blocked( + "isolated repository copy exceeds atomic recovery entry or path bounds", + )); + } + let metadata = fs::symlink_metadata(child.path()) + .map_err(|source| recovery_transaction_io("inspect repository copy", source))?; + if metadata.file_type().is_dir() { + pending.push(relative); + } else if metadata.file_type().is_file() { + file_bytes = file_bytes.saturating_add(metadata.len()); + if file_bytes > file_limit { + return Err(recovery_transaction_blocked( + "isolated repository copy exceeds atomic recovery content bound", + )); + } + } + } + } + Ok(()) +} + #[cfg(test)] static RECOVERY_COMMAND_DURATIONS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3174,6 +3424,16 @@ fn run_bounded_recovery_command( command: &mut Command, args: Vec, duration: std::time::Duration, +) -> Result { + run_bounded_recovery_process(command, args, duration, "Git", None) +} + +fn run_bounded_recovery_process( + command: &mut Command, + args: Vec, + duration: std::time::Duration, + description: &str, + mut progress_guard: Option<&mut dyn FnMut() -> Result<(), GitError>>, ) -> Result { #[cfg(target_os = "linux")] { @@ -3218,6 +3478,8 @@ fn run_bounded_recovery_command( std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes, stderr_stop)); let started = std::time::Instant::now(); let mut deadline_exceeded = false; + let mut guard_error = None; + let mut last_guard = started; let status = loop { if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { #[cfg(target_os = "linux")] @@ -3236,6 +3498,20 @@ fn run_bounded_recovery_command( let status = child.wait(); break termination.and(status); } + if last_guard.elapsed() >= std::time::Duration::from_millis(25) { + last_guard = std::time::Instant::now(); + if let Some(guard) = progress_guard.as_mut() + && let Err(error) = guard() + { + guard_error = Some(error); + #[cfg(target_os = "linux")] + let termination = terminate_recovery_process_group(&mut child); + #[cfg(not(target_os = "linux"))] + let termination = child.kill(); + let status = child.wait(); + break termination.and(status); + } + } match child.try_wait() { Ok(Some(status)) => break Ok(status), Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), @@ -3275,17 +3551,23 @@ fn run_bounded_recovery_command( args: args.clone(), source, })?; + if let Some(error) = guard_error { + return Err(error); + } if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { return Err(recovery_bound_error(format!( - "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" + "{description} output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" ))); } if deadline_exceeded { return Err(recovery_bound_error(format!( - "Git command exceeded its {}ms execution deadline", + "{description} exceeded its {}ms execution deadline", duration.as_millis() ))); } + if let Some(guard) = progress_guard.as_mut() { + guard()?; + } Ok(RawProcessOutput { args, status, @@ -3573,7 +3855,7 @@ fn recovery_metadata_file<'a>( ) -> Option<&'a [u8]> { metadata.iter().find_map(|entry| { if entry.path == Path::new(relative) { - if let RecoveryMetadataValue::File(contents) = &entry.value { + if let RecoveryMetadataValue::File { contents, .. } = &entry.value { return Some(contents.as_slice()); } } @@ -3586,7 +3868,8 @@ fn rebase_original_head(metadata: &[RecoveryMetadataEntry]) -> Result>(); @@ -3614,12 +3897,24 @@ fn rebase_original_head(metadata: &[RecoveryMetadataEntry]) -> Result Result, GitError> { + snapshot_recovery_paths( + RECOVERY_METADATA_PATHS + .iter() + .map(|relative| (PathBuf::from(relative), git_dir.join(relative))), + capture, + ) +} + +fn snapshot_recovery_paths( + paths: impl IntoIterator, + capture: &mut RecoveryCapture, ) -> Result, GitError> { let mut entries = Vec::new(); - let mut pending = Vec::new(); - for relative in RECOVERY_METADATA_PATHS.iter().rev() { + let mut pending = paths.into_iter().collect::>(); + pending.reverse(); + for _ in &pending { capture.reserve_metadata_entry()?; - pending.push((PathBuf::from(relative), git_dir.join(relative))); } while let Some((relative, path)) = pending.pop() { @@ -3638,7 +3933,9 @@ fn snapshot_recovery_metadata( let file_type = metadata.file_type(); let value = if file_type.is_dir() { capture.reserve_metadata_bytes(relative.as_os_str().as_encoded_bytes().len())?; - RecoveryMetadataValue::Directory + RecoveryMetadataValue::Directory { + mode: recovery_file_mode(&metadata), + } } else if file_type.is_file() { let maximum = MAX_RECOVERY_METADATA_BYTES.saturating_sub(capture.metadata_bytes); let mut contents = Vec::new(); @@ -3659,7 +3956,10 @@ fn snapshot_recovery_metadata( .len() .saturating_add(contents.len()), )?; - RecoveryMetadataValue::File(contents) + RecoveryMetadataValue::File { + contents, + mode: recovery_file_mode(&opened_metadata), + } } else if file_type.is_symlink() { let target = fs::read_link(&path) .map_err(|source| recovery_metadata_io_error(&relative, source))?; @@ -3916,6 +4216,21 @@ pub struct RecoveryState { worktree: Vec, metadata: Vec, refs: Vec, + execution: RecoveryExecutionState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecoveryExecutionState { + config: Vec, + config_files: Vec, + hooks_location: RecoveryHookLocation, + hooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecoveryHookLocation { + Repository(PathBuf), + External(PathBuf), } impl RecoveryState { @@ -3931,6 +4246,16 @@ impl RecoveryState { } } +impl RecoveryExecutionState { + fn hooks_are_repository_local(&self) -> bool { + matches!(self.hooks_location, RecoveryHookLocation::Repository(_)) + && !self + .hooks + .iter() + .any(|entry| matches!(entry.value, RecoveryMetadataValue::Symlink(_))) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RecoveryWorktreeEntry { path: PathBuf, @@ -3962,8 +4287,8 @@ struct RecoveryMetadataEntry { #[derive(Debug, Clone, PartialEq, Eq)] enum RecoveryMetadataValue { Missing, - Directory, - File(Vec), + Directory { mode: u32 }, + File { contents: Vec, mode: u32 }, Symlink(PathBuf), } @@ -5276,8 +5601,10 @@ mod tests { let second_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? .ok_or("missing replacement recovery backup")?; assert_ne!(second_backup, first_backup); - assert!(!first_backup.exists()); + assert!(first_backup.is_dir()); assert!(second_backup.is_dir()); + fs::remove_dir_all(&first_backup)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); let skip_repo = prepare_two_conflict_rebase()?; let skip_git = Git::new(skip_repo.path()); @@ -5333,6 +5660,170 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_copy_bounds_concurrent_tree_growth() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let baseline = RecoveryGeneration::capture(&root)?; + RECOVERY_COPY_LIMIT_OVERRIDES + .lock() + .map_err(|_| "recovery copy limit lock poisoned")? + .push(( + root.clone(), + ( + baseline.entries.len() + 1, + MAX_RECOVERY_GENERATION_PATH_BYTES, + MAX_RECOVERY_GENERATION_FILE_BYTES, + ), + )); + let growth_root = root.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write(growth_root.join("copy-growth-a"), "a\n"); + let _ = fs::write(growth_root.join("copy-growth-b"), "b\n"); + }); + RECOVERY_COPY_HOOKS + .lock() + .map_err(|_| "recovery copy hook lock poisoned")? + .push((root.clone(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected copy-time tree growth to exceed traversal bounds".into()); + }; + + assert!( + error.to_string().contains("repository copy exceeds"), + "{error}" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(fs::read_to_string(root.join("copy-growth-a"))?, "a\n"); + assert_eq!(fs::read_to_string(root.join("copy-growth-b"))?, "b\n"); + fs::remove_file(root.join("copy-growth-a"))?; + fs::remove_file(root.join("copy-growth-b"))?; + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_copy_bounds_diagnostics_without_promoting() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let script = repo.path().with_extension("noisy-copy"); + fs::write( + &script, + "#!/bin/sh\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\n", + )?; + let mut permissions = fs::metadata(&script)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions)?; + RECOVERY_COPY_PROGRAMS + .lock() + .map_err(|_| "recovery copy program lock poisoned")? + .push((root, script.clone().into_os_string())); + + let started = std::time::Instant::now(); + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected noisy repository copy to exceed output bounds".into()); + }; + + assert!( + error + .to_string() + .contains("repository copy output bytes exceed") + ); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_file(script)?; + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_copy_deadline_terminates_stalled_input() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let stalled = repo.path().with_extension("stalled-copy-input"); + if !Command::new("mkfifo").arg(&stalled).status()?.success() { + return Err("mkfifo failed".into()); + } + let script = repo.path().with_extension("stalled-copy"); + fs::write( + &script, + format!( + "#!/bin/sh\ndd if={} of=/dev/null bs=1 2>/dev/null\n", + shell_quote(&stalled.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&script)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions)?; + RECOVERY_COPY_PROGRAMS + .lock() + .map_err(|_| "recovery copy program lock poisoned")? + .push((root.clone(), script.clone().into_os_string())); + RECOVERY_COPY_DURATIONS + .lock() + .map_err(|_| "recovery copy duration lock poisoned")? + .push((root, std::time::Duration::from_secs(1))); + + let started = std::time::Instant::now(); + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected stalled repository copy to exceed its deadline".into()); + }; + + assert!( + error.to_string().contains("repository copy exceeded"), + "{error}" + ); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + assert_eq!(git.recovery_state()?, expected); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_file(script)?; + fs::remove_file(stalled)?; + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn recovery_bounds_hook_output_without_promoting() -> Result<(), Box> { @@ -5581,6 +6072,58 @@ mod tests { Ok(()) } + #[test] + fn retained_backup_cleanup_preserves_write_after_validation_starts() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let mut stale_file = fs::OpenOptions::new() + .write(true) + .open(repo.path().join("conflict.txt"))?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let root = repo.path().canonicalize()?; + let backup = + recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; + repo.run_allow_failure(["merge", "other"])?; + let expected = git.recovery_state()?; + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = stale_file.set_len(0); + let _ = stale_file.write_all(b"write after cleanup validation\n"); + let _ = stale_file.sync_all(); + }); + RECOVERY_CLEANUP_HOOKS + .lock() + .map_err(|_| "recovery cleanup hook lock poisoned")? + .push((backup.clone(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected late retained-backup write to block cleanup".into()); + }; + + assert!( + error.to_string().contains("changed after promotion"), + "{error}" + ); + assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); + assert_eq!( + fs::read_to_string(backup.join("conflict.txt"))?, + "write after cleanup validation\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn retained_backup_follows_repository_rename() -> Result<(), Box> { let (mut repo, _original_head) = prepare_merge_conflict()?; @@ -5606,8 +6149,10 @@ mod tests { git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert!(!first_backup.exists()); + assert!(first_backup.is_dir()); assert!(recovery_backup_from_pointer(&repo.path().canonicalize()?)?.is_some()); + fs::remove_dir_all(&first_backup)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); Ok(()) } @@ -6468,6 +7013,12 @@ mod tests { worktree: Vec::new(), metadata: Vec::new(), refs: Vec::new(), + execution: RecoveryExecutionState { + config: Vec::new(), + config_files: Vec::new(), + hooks_location: RecoveryHookLocation::Repository(PathBuf::new()), + hooks: Vec::new(), + }, }; let Err(error) = Git::new(repo).recover_exact( RepositoryOperation::Merge, @@ -6538,6 +7089,137 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn exact_recovery_rejects_new_external_hooks_path_after_preview() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let hooks = repo.path().with_extension("external-hooks"); + let marker = repo.path().with_extension("external-hook-ran"); + fs::create_dir(&hooks)?; + let hook = hooks.join("reference-transaction"); + fs::write( + &hook, + format!( + "#!/bin/sh\ntouch {}\n", + shell_quote(&marker.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run_args(&["config", "core.hooksPath", hooks.to_string_lossy().as_ref()])?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected a new external hooks path to invalidate the preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_dir_all(hooks)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn exact_recovery_rejects_hook_content_changed_after_preview() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let hooks = repo.path().join("hooks"); + fs::create_dir(&hooks)?; + let marker = repo.path().with_extension("changed-hook-ran"); + let hook = hooks.join("reference-transaction"); + fs::write(&hook, "#!/bin/sh\nexit 0\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run(["config", "core.hooksPath", "hooks"])?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + fs::write( + &hook, + format!( + "#!/bin/sh\ntouch {}\n", + shell_quote(&marker.to_string_lossy()) + ), + )?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed hook content to invalidate the preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn exact_recovery_revalidates_hook_content_after_copy_preparation() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let hook = repo.path().join(".git/hooks/reference-transaction"); + fs::write(&hook, "#!/bin/sh\nexit 0\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let marker = repo.path().with_extension("late-hook-ran"); + let changed_hook = hook.clone(); + let changed_marker = marker.clone(); + let prepare_hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write( + changed_hook, + format!( + "#!/bin/sh\ntouch {}\n", + shell_quote(&changed_marker.to_string_lossy()) + ), + ); + }); + RECOVERY_PREPARE_HOOKS + .lock() + .map_err(|_| "recovery prepare hook lock poisoned")? + .push((repo.path().canonicalize()?, prepare_hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected hook change during copy preparation to block recovery".into()); + }; + + assert!( + error + .to_string() + .contains("configuration, or hooks changed while isolated recovery was prepared"), + "{error}" + ); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_merge_metadata() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -7000,7 +7682,8 @@ mod tests { return Err("expected concurrent edit to block recovery".into()); }; editor.join().map_err(|_| "editor thread panicked")?; - assert!(error.to_string().contains("repository changed")); + let message = error.to_string(); + assert!(message.contains("changed"), "{message}"); assert_eq!( fs::read_to_string(repo.path().join("conflict.txt"))?, "concurrent edit\n" @@ -7024,6 +7707,7 @@ mod tests { return Ok(()); } let (signal, release) = install_recovery_prepared_barrier(&repo)?; + let expected = git.recovery_state()?; let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?; let preview_conflict = fs::read(repo.path().join("conflict.txt"))?; let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); @@ -7084,6 +7768,7 @@ mod tests { return Ok(()); } let (signal, release) = install_recovery_prepared_barrier(&repo)?; + let expected = git.recovery_state()?; let worker_path = repo.path(); let worker_state = expected.clone(); let worker = std::thread::spawn(move || { @@ -7139,6 +7824,7 @@ mod tests { return Ok(()); } let (signal, release) = install_recovery_prepared_barrier(&repo)?; + let expected = git.recovery_state()?; let original_blob = repo .git_stdout(["rev-parse", "HEAD:conflict.txt"])? .trim() @@ -7233,6 +7919,64 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_rollback_retains_post_exchange_write() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let root = repo.path().canonicalize()?; + let written = root.join("post-exchange-write"); + let hook_written = written.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write(hook_written, "must be retained\n"); + }); + RECOVERY_POST_EXCHANGE_HOOKS + .lock() + .map_err(|_| "post-exchange hook lock poisoned")? + .push((root.clone(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected post-exchange write to roll back promotion".into()); + }; + + assert!( + error + .to_string() + .contains("post-exchange generation was retained") + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert!(!written.exists()); + let parent = root.parent().ok_or("test repository has no parent")?; + let retained = fs::read_dir(parent)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name().is_some_and(|name| { + name.as_encoded_bytes() + .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + }) && path.join("post-exchange-write").is_file() + }) + .ok_or("post-exchange generation was not retained")?; + assert_eq!( + fs::read_to_string(retained.join("post-exchange-write"))?, + "must be retained\n" + ); + fs::remove_dir_all(&retained)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&retained)); + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_leaves_configured_hook_namespace_and_config_unchanged() -> Result<(), Box> From 8917d611cc55a004327c2b49ae07fe7c150ef9b6 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:42:57 -0400 Subject: [PATCH 25/53] fix: fence recovery generations and processes --- README.md | 8 +- crates/bitbygit-git/src/lib.rs | 522 ++++++++++++++++++++++++++------- docs/guardrails.md | 9 +- 3 files changed, 432 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index f2d3cad..77df113 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,12 @@ Prerequisites: - `git` available on `PATH`. - `gh` is optional for future GitHub workflows. -Conflict recovery is supported on Linux hosts that provide unprivileged user -and mount namespaces, bind mounts, and same-filesystem atomic directory +Conflict recovery is supported on Linux hosts that provide unprivileged user, +mount, and PID namespaces, bind mounts, and same-filesystem atomic directory exchange. It fails closed without changing the repository on other platforms -or when those Linux capabilities are restricted. +or when those Linux capabilities are restricted. Recovery retains one previous +repository generation; inspect and move or delete the reported directory before +running another recovery. Run local checks: diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 4cfc624..2199a3e 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -66,6 +66,12 @@ const RECOVERY_GIT_ENVIRONMENT: &[&str] = &[ "GIT_SHALLOW_FILE", "GIT_WORK_TREE", ]; +const RECOVERY_CONFIG_ENVIRONMENT: &[&str] = &[ + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", +]; const RECOVERY_CAPABILITY_UNAVAILABLE: &str = "atomic recovery is unavailable because required platform capabilities are missing"; @@ -903,7 +909,17 @@ impl Git { git_dir: &Path, capture: &mut RecoveryCapture, ) -> Result { - let config = capture.required(self, &["config", "--null", "--show-scope", "--list"])?; + let config = capture.required( + self, + &[ + "config", + "--null", + "--show-scope", + "--show-origin", + "--list", + ], + )?; + let config = repository_local_recovery_config(config, root, git_dir)?; capture.retain(config.len(), "effective recovery configuration")?; let config_files = snapshot_recovery_paths( [ @@ -1572,6 +1588,7 @@ impl RecoveryTransaction { })?; run_recovery_prepare_hook(&root); remove_previous_recovery_backup(&root)?; + ensure_no_retained_recovery_generation(&root)?; let baseline = RecoveryGeneration::capture(&root)?; let root_identity = recovery_file_identity(&fs::symlink_metadata(&root).map_err(|source| { @@ -1615,14 +1632,17 @@ impl RecoveryTransaction { } fn supported_root(git: &Git) -> Result { - let root = git - .repo_root()? - .canonicalize() - .map_err(|source| recovery_transaction_io("resolve repository root", source))?; - let git_dir = git - .git_path("")? - .canonicalize() - .map_err(|source| recovery_transaction_io("resolve Git directory", source))?; + let mut capture = RecoveryCapture::default(); + let root = path_from_bytes(strip_byte_line_ending( + &capture.required(git, &["rev-parse", "--show-toplevel"])?, + )) + .canonicalize() + .map_err(|source| recovery_transaction_io("resolve repository root", source))?; + let git_dir = path_from_bytes(strip_byte_line_ending( + &capture.required(git, &["rev-parse", "--absolute-git-dir"])?, + )) + .canonicalize() + .map_err(|source| recovery_transaction_io("resolve Git directory", source))?; let embedded_git_dir = root.join(".git"); if !embedded_git_dir.is_dir() || embedded_git_dir.canonicalize().map_err(|source| { @@ -1633,10 +1653,10 @@ impl RecoveryTransaction { "atomic recovery requires a standalone repository with an embedded .git directory", )); } - let common_dir = path_from_bytes(strip_byte_line_ending( - &git.run_raw(["rev-parse", "--path-format=absolute", "--git-common-dir"])? - .stdout, - )) + let common_dir = path_from_bytes(strip_byte_line_ending(&capture.required( + git, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )?)) .canonicalize() .map_err(|source| recovery_transaction_io("resolve common Git directory", source))?; if common_dir != git_dir { @@ -1706,8 +1726,12 @@ impl RecoveryTransaction { ) -> Result { let candidate_git = Git::new(&self.candidate); let mut capture = RecoveryCapture::default(); - let candidate_root = candidate_git.repo_root()?; - let candidate_git_dir = candidate_git.git_path("")?; + let candidate_root = path_from_bytes(strip_byte_line_ending( + &capture.required(&candidate_git, &["rev-parse", "--show-toplevel"])?, + )); + let candidate_git_dir = path_from_bytes(strip_byte_line_ending( + &capture.required(&candidate_git, &["rev-parse", "--absolute-git-dir"])?, + )); let candidate_execution = candidate_git.recovery_execution_state( &candidate_root, &candidate_git_dir, @@ -1719,12 +1743,15 @@ impl RecoveryTransaction { "repository state, configuration, or hooks changed while isolated recovery was prepared", )); } + run_recovery_execution_hook(&self.root); let mut command = Command::new("unshare"); command .args([ "--user", "--map-root-user", "--mount", + "--pid", + "--kill-child=SIGKILL", "--fork", "sh", "-c", @@ -1740,6 +1767,7 @@ impl RecoveryTransaction { .env("SSH_ASKPASS_REQUIRE", "never") .env("GIT_EDITOR", "true") .env("GIT_SEQUENCE_EDITOR", "true"); + isolate_recovery_git_environment(&mut command); for variable in RECOVERY_GIT_ENVIRONMENT { command.env_remove(variable); } @@ -1786,9 +1814,17 @@ impl RecoveryTransaction { let Ok(candidate_state) = candidate_git.recovery_state() else { return false; }; - let Ok(candidate_status) = candidate_git.status() else { + let mut capture = RecoveryCapture::default(); + let Ok(status) = capture.required( + &candidate_git, + &["status", "--porcelain=v2", "--branch", "-z"], + ) else { return false; }; + let Ok(mut candidate_status) = parse_status_bytes(&status) else { + return false; + }; + candidate_status.operation = candidate_state.operation; candidate_state.operation == Some(RepositoryOperation::Rebase) && candidate_status.operation == Some(RepositoryOperation::Rebase) && !candidate_status.conflicted_files().is_empty() @@ -1823,6 +1859,7 @@ impl RecoveryTransaction { run_recovery_post_exchange_hook(&self.root); let old_generation = RecoveryGeneration::capture(&self.candidate); let installed_generation = RecoveryGeneration::capture_isolated(&self.root); + run_recovery_installed_capture_hook(&self.root); let identities_match = recovery_path_identity(&self.root) .is_ok_and(|identity| identity == self.candidate_identity) && recovery_path_identity(&self.candidate) @@ -1836,25 +1873,17 @@ impl RecoveryTransaction { "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" ))); } - let installed_was_unchanged = matches!( - &installed_generation, - Ok(generation) if generation == &candidate_generation - ); - self.keep_candidate = !installed_was_unchanged; + self.keep_candidate = true; return Err(recovery_transaction_blocked( - match ( - old_generation, - installed_generation, - installed_was_unchanged, - ) { - (_, _, false) => format!( - "repository changed during atomic recovery promotion; the post-exchange generation was retained at {}", + match (old_generation, installed_generation) { + (Err(error), _) | (_, Err(error)) => format!( + "repository metadata changed during atomic recovery promotion; the rolled-back generation was retained at {}: {error}", self.candidate.display() ), - (Err(error), _, _) | (_, Err(error), _) => format!( - "repository metadata changed during atomic recovery promotion: {error}" + _ => format!( + "repository changed during atomic recovery promotion; the rolled-back generation was retained at {}", + self.candidate.display() ), - _ => "repository changed during atomic recovery promotion".to_owned(), }, )); } @@ -2762,7 +2791,6 @@ fn recovery_backup_pointer_path(root: &Path) -> Result { struct RecoveryBackup { path: PathBuf, - generation_digest: [u8; RECOVERY_GENERATION_DIGEST_BYTES], } fn recovery_backup_record_from_pointer( @@ -2793,7 +2821,7 @@ fn recovery_backup_record_from_pointer( if record.len() != RECOVERY_BACKUP_IDENTITY_BYTES + RECOVERY_GENERATION_DIGEST_BYTES { return Err(invalid_recovery_backup_pointer()); } - let (identity, generation_digest) = record.split_at(RECOVERY_BACKUP_IDENTITY_BYTES); + let (identity, _generation_digest) = record.split_at(RECOVERY_BACKUP_IDENTITY_BYTES); let valid_location = backup.is_absolute() && backup.file_name().is_some_and(|name| { name.as_encoded_bytes() @@ -2834,12 +2862,7 @@ fn recovery_backup_record_from_pointer( { return Err(invalid_recovery_backup_pointer()); } - let mut digest = [0_u8; RECOVERY_GENERATION_DIGEST_BYTES]; - digest.copy_from_slice(generation_digest); - Ok(Some(RecoveryBackup { - path: backup, - generation_digest: digest, - })) + Ok(Some(RecoveryBackup { path: backup })) } #[cfg(test)] @@ -2847,6 +2870,79 @@ fn recovery_backup_from_pointer(expected_root: &Path) -> Result, Ok(recovery_backup_record_from_pointer(expected_root)?.map(|backup| backup.path)) } +fn ensure_no_retained_recovery_generation(root: &Path) -> Result<(), GitError> { + let parent = root.parent().ok_or_else(|| { + recovery_transaction_blocked("repository root has no parent for retained recovery checks") + })?; + let root_identity = recovery_path_identity(root)?; + let mut entries = 0_usize; + for entry in fs::read_dir(parent).map_err(|source| { + recovery_transaction_io("inspect retained recovery generations", source) + })? { + let entry = entry.map_err(|source| { + recovery_transaction_io("inspect retained recovery generation", source) + })?; + entries = entries.saturating_add(1); + if entries > MAX_RECOVERY_GENERATION_ENTRIES { + return Err(recovery_transaction_blocked( + "too many sibling entries to validate retained recovery generations", + )); + } + let path = entry.path(); + if !entry + .file_name() + .as_encoded_bytes() + .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + || !entry + .file_type() + .map_err(|source| { + recovery_transaction_io("inspect retained recovery generation", source) + })? + .is_dir() + { + continue; + } + let candidate_identity = match recovery_path_identity(&path) { + Ok(identity) => identity, + Err(_) if !path.exists() => continue, + Err(error) => return Err(error), + }; + let owner = recovery_candidate_owner_path(&path); + let Ok(metadata) = fs::symlink_metadata(&owner) else { + continue; + }; + let Ok(identity) = read_recovery_sidecar( + &owner, + &metadata, + RECOVERY_BACKUP_IDENTITY_BYTES as u64, + Some(RECOVERY_BACKUP_IDENTITY_BYTES as u64), + ) else { + continue; + }; + let root_first = root_identity.0.to_le_bytes() == identity[..8] + && root_identity.1.to_le_bytes() == identity[8..16] + && candidate_identity.0.to_le_bytes() == identity[16..24] + && candidate_identity.1.to_le_bytes() == identity[24..32]; + let candidate_first = candidate_identity.0.to_le_bytes() == identity[..8] + && candidate_identity.1.to_le_bytes() == identity[8..16] + && root_identity.0.to_le_bytes() == identity[16..24] + && root_identity.1.to_le_bytes() == identity[24..32]; + let mut expected_owner = Sha256::new(); + expected_owner.update(root.as_os_str().as_encoded_bytes()); + expected_owner.update(path.as_os_str().as_encoded_bytes()); + expected_owner.update(&identity[..32]); + if (root_first || candidate_first) + && expected_owner.finalize().as_slice() == &identity[32..] + { + return Err(recovery_transaction_blocked(format!( + "a previous repository generation is retained at {}; inspect and move or delete it before retrying recovery", + path.display() + ))); + } + } + Ok(()) +} + fn read_recovery_sidecar( path: &Path, expected: &fs::Metadata, @@ -2884,32 +2980,70 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { let Some(backup) = recovery_backup_record_from_pointer(root)? else { return Ok(()); }; - let generation = match RecoveryGeneration::capture(&backup.path) { - Ok(generation) if generation.digest() != backup.generation_digest => { - return Err(recovery_transaction_blocked(format!( - "the retained recovery backup at {} changed after promotion; refusing to remove it", - backup.path.display() - ))); - } - Ok(generation) => Some(generation), - Err(_) if !backup.path.exists() => None, - Err(error) => return Err(error), - }; - run_recovery_cleanup_hook(&backup.path); - if let Some(generation) = generation { - let current = RecoveryGeneration::capture(&backup.path)?; - if current != generation || current.digest() != backup.generation_digest { - return Err(recovery_transaction_blocked(format!( - "the retained recovery backup at {} changed after promotion; refusing to remove it", - backup.path.display() - ))); - } + if backup.path.exists() { + return Err(recovery_transaction_blocked(format!( + "a previous repository generation is retained at {}; inspect and move or delete it before retrying recovery", + backup.path.display() + ))); } fs::remove_file(recovery_backup_pointer_path(root)?) .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; Ok(()) } +fn repository_local_recovery_config( + raw: Vec, + root: &Path, + git_dir: &Path, +) -> Result, GitError> { + let mut fields = raw.split(|byte| *byte == 0).collect::>(); + if fields.last() == Some(&b"".as_slice()) { + fields.pop(); + } + if fields.len() % 3 != 0 { + return Err(recovery_transaction_blocked( + "effective recovery configuration has an invalid record format", + )); + } + let allowed = [git_dir.join("config"), git_dir.join("config.worktree")]; + let mut config = Vec::with_capacity(raw.len()); + for field in fields.chunks_exact(3) { + let Some(origin) = field[1].strip_prefix(b"file:") else { + return Err(recovery_transaction_blocked( + "atomic recovery requires configuration stored in the repository", + )); + }; + let origin_path = path_from_bytes(origin); + let origin_path = if origin_path.is_absolute() { + origin_path + } else { + root.join(origin_path) + }; + if !allowed.iter().any(|path| path == &origin_path) { + return Err(recovery_transaction_blocked(format!( + "atomic recovery does not support configuration included from {}", + origin_path.display() + ))); + } + config.extend_from_slice(field[0]); + config.push(0); + config.extend_from_slice(field[2]); + config.push(0); + } + Ok(config) +} + +fn isolate_recovery_git_environment(command: &mut Command) { + for variable in RECOVERY_CONFIG_ENVIRONMENT { + command.env_remove(variable); + } + command + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_ATTR_NOSYSTEM", "1"); +} + fn ensure_recovery_environment_isolated() -> Result<(), GitError> { if let Some(variable) = RECOVERY_GIT_ENVIRONMENT .iter() @@ -2927,7 +3061,7 @@ fn append_recovery_backup_notice(output: &mut String, backup: &Path) { output.push('\n'); } output.push_str(&format!( - "bitbygit: previous repository generation retained at {}; it will remain available for explicit cleanup after a later recovery validates it as unchanged\n", + "bitbygit: previous repository generation retained at {}; inspect and move or delete it before another recovery\n", backup.display() )); } @@ -2949,6 +3083,8 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> "--user", "--map-root-user", "--mount", + "--pid", + "--kill-child=SIGKILL", "--fork", "sh", "-c", @@ -3085,6 +3221,23 @@ fn run_recovery_prepare_hook(root: &Path) { #[cfg(not(test))] fn run_recovery_prepare_hook(_root: &Path) {} +#[cfg(test)] +static RECOVERY_EXECUTION_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_execution_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_EXECUTION_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_execution_hook(_root: &Path) {} + #[cfg(test)] static RECOVERY_PROMOTION_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3119,6 +3272,23 @@ fn run_recovery_post_exchange_hook(root: &Path) { #[cfg(not(test))] fn run_recovery_post_exchange_hook(_root: &Path) {} +#[cfg(test)] +static RECOVERY_INSTALLED_CAPTURE_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_installed_capture_hook(root: &Path) { + if let Ok(mut hooks) = RECOVERY_INSTALLED_CAPTURE_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == root) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_installed_capture_hook(_root: &Path) {} + #[cfg(test)] static RECOVERY_CLEANUP_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -3353,6 +3523,7 @@ impl RecoveryCapture { .env("SSH_ASKPASS", "") .env("SSH_ASKPASS_REQUIRE", "never") .args(&args); + isolate_recovery_git_environment(&mut command); let output = run_bounded_recovery_command( &mut command, display_args.clone(), @@ -5583,6 +5754,8 @@ mod tests { let first_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? .ok_or("missing retained recovery backup")?; assert!(first_backup.is_dir()); + let archived_backup = continue_repo.path().with_extension("first-recovery-backup"); + fs::rename(&first_backup, &archived_backup)?; continue_repo.write("second.txt", "topic second\n")?; continue_repo.run(["add", "second.txt"])?; @@ -5601,9 +5774,9 @@ mod tests { let second_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? .ok_or("missing replacement recovery backup")?; assert_ne!(second_backup, first_backup); - assert!(first_backup.is_dir()); + assert!(archived_backup.is_dir()); assert!(second_backup.is_dir()); - fs::remove_dir_all(&first_backup)?; + fs::remove_dir_all(&archived_backup)?; let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); let skip_repo = prepare_two_conflict_rebase()?; @@ -6025,6 +6198,49 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_pid_namespace_terminates_setsid_hook_descendants() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + let delayed_write = repo.path().join("escaped-hook-write"); + fs::write( + &hook, + format!( + "#!/bin/sh\nsetsid sh -c 'sleep 1; printf escaped > \"$1\"' bitbygit {} /dev/null 2>&1 &\n", + shell_quote(&delayed_write.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + + git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )?; + std::thread::sleep(std::time::Duration::from_millis(1500)); + + assert!(!delayed_write.exists()); + assert_eq!(git.status()?.operation, None); + Ok(()) + } + #[test] fn changed_retained_backup_blocks_later_recovery() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -6062,7 +6278,7 @@ mod tests { return Err("expected changed retained backup to block later recovery".into()); }; - assert!(error.to_string().contains("changed after promotion")); + assert!(error.to_string().contains("inspect and move or delete it")); assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); assert_eq!( fs::read_to_string(backup.join("conflict.txt"))?, @@ -6073,7 +6289,7 @@ mod tests { } #[test] - fn retained_backup_cleanup_preserves_write_after_validation_starts() + fn retained_backup_blocks_automatic_cleanup_without_deleting_late_writes() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; let mut stale_file = fs::OpenOptions::new() @@ -6095,30 +6311,23 @@ mod tests { recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; repo.run_allow_failure(["merge", "other"])?; let expected = git.recovery_state()?; - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = stale_file.set_len(0); - let _ = stale_file.write_all(b"write after cleanup validation\n"); - let _ = stale_file.sync_all(); - }); - RECOVERY_CLEANUP_HOOKS - .lock() - .map_err(|_| "recovery cleanup hook lock poisoned")? - .push((backup.clone(), hook)); - let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err("expected late retained-backup write to block cleanup".into()); + return Err("expected retained backup to block automatic cleanup".into()); }; + stale_file.set_len(0)?; + stale_file.write_all(b"write after blocked cleanup\n")?; + stale_file.sync_all()?; assert!( - error.to_string().contains("changed after promotion"), + error.to_string().contains("inspect and move or delete it"), "{error}" ); assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); assert_eq!( fs::read_to_string(backup.join("conflict.txt"))?, - "write after cleanup validation\n" + "write after blocked cleanup\n" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) @@ -6147,11 +6356,19 @@ mod tests { let git = Git::new(repo.path()); let expected = git.recovery_state()?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected retained backup to block recovery after rename".into()); + }; + assert!(error.to_string().contains("inspect and move or delete it")); + let archived = repo.path().with_extension("archived-recovery-backup"); + fs::rename(&first_backup, &archived)?; git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert!(first_backup.is_dir()); + assert!(archived.is_dir()); assert!(recovery_backup_from_pointer(&repo.path().canonicalize()?)?.is_some()); - fs::remove_dir_all(&first_backup)?; + fs::remove_dir_all(&archived)?; let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); Ok(()) } @@ -6398,29 +6615,14 @@ mod tests { let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; repo.run_allow_failure(["merge", "other"])?; let expected = git.recovery_state()?; - let late_mount_target = backup.join("nested-mount"); - let hook_source = source.clone(); - let hook_target = late_mount_target.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _result = Command::new("mount") - .args(["--bind"]) - .arg(hook_source) - .arg(hook_target) - .status(); - }); - RECOVERY_CLEANUP_HOOKS - .lock() - .map_err(|_| "recovery cleanup hook lock poisoned")? - .push((backup.clone(), hook)); let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err("nested backup mount was not blocked before later recovery cleanup".into()); + return Err("retained backup was not blocked before later recovery".into()); }; - let mut guard = BindMountGuard(Some(late_mount_target)); assert!( - error.to_string().contains("mounted filesystem entry"), + error.to_string().contains("inspect and move or delete it"), "{error}" ); assert_eq!(git.recovery_state()?, expected); @@ -6430,7 +6632,6 @@ mod tests { "must survive recovery\n" ); - guard.unmount()?; fs::remove_dir_all(source)?; Ok(()) } @@ -7127,6 +7328,113 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn exact_recovery_fences_global_config_changed_after_final_capture() + -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + const CHILD: &str = "BITBYGIT_TEST_GLOBAL_CONFIG_FENCE"; + if env::var_os(CHILD).is_none() { + let support = TempRepo::new()?; + let config = support.path().join("global-config"); + let hooks = support.path().join("global-hooks"); + let marker = support.path().join("external-hook-ran"); + fs::create_dir(&hooks)?; + fs::write(&config, "")?; + let output = Command::new(env::current_exe()?) + .args([ + "--exact", + "tests::exact_recovery_fences_global_config_changed_after_final_capture", + "--nocapture", + ]) + .env(CHILD, "1") + .env("GIT_CONFIG_GLOBAL", &config) + .env("BITBYGIT_TEST_GLOBAL_HOOKS", &hooks) + .env("BITBYGIT_TEST_GLOBAL_HOOK_MARKER", &marker) + .output()?; + assert!( + output.status.success(), + "global-config fence child failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!marker.exists()); + return Ok(()); + } + + let config = PathBuf::from(env::var_os("GIT_CONFIG_GLOBAL").ok_or("missing config")?); + let hooks = + PathBuf::from(env::var_os("BITBYGIT_TEST_GLOBAL_HOOKS").ok_or("missing hooks path")?); + let marker = PathBuf::from( + env::var_os("BITBYGIT_TEST_GLOBAL_HOOK_MARKER").ok_or("missing hook marker")?, + ); + let hook = hooks.join("reference-transaction"); + fs::write( + &hook, + format!( + "#!/bin/sh\ntouch {}\n", + shell_quote(&marker.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + return Ok(()); + } + let changed_config = config.clone(); + let configured_hooks = hooks.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write( + changed_config, + format!("[core]\n\thooksPath = {}\n", configured_hooks.display()), + ); + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((repo.path().canonicalize()?, hook)); + + git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )?; + + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, None); + Ok(()) + } + + #[test] + fn recovery_rejects_local_config_including_external_storage() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let external = repo.path().with_extension("included-config"); + fs::write(&external, "[core]\n\thooksPath = hooks\n")?; + let config = repo.path().join(".git/config"); + let mut contents = fs::read_to_string(&config)?; + contents.push_str(&format!("\n[include]\n\tpath = {}\n", external.display())); + fs::write(config, contents)?; + + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("external config include should be rejected".into()); + }; + + assert!(error.to_string().contains("configuration included from")); + fs::remove_file(external)?; + Ok(()) + } + #[cfg(unix)] #[test] fn exact_recovery_rejects_hook_content_changed_after_preview() -> Result<(), Box> { @@ -7923,6 +8231,9 @@ mod tests { #[test] fn atomic_promotion_rollback_retains_post_exchange_write() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; + let mut stale_file = fs::OpenOptions::new() + .write(true) + .open(repo.path().join("conflict.txt"))?; let git = Git::new(repo.path()); let expected = git.recovery_state()?; if !recovery_capabilities_or_verify_fail_closed( @@ -7936,13 +8247,22 @@ mod tests { let root = repo.path().canonicalize()?; let written = root.join("post-exchange-write"); let hook_written = written.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write(hook_written, "must be retained\n"); + let stale_hook: RecoveryCaptureHook = Box::new(move || { + let _ = stale_file.set_len(0); + let _ = stale_file.write_all(b"force rollback after exchange\n"); + let _ = stale_file.sync_all(); }); RECOVERY_POST_EXCHANGE_HOOKS .lock() .map_err(|_| "post-exchange hook lock poisoned")? - .push((root.clone(), hook)); + .push((root.clone(), stale_hook)); + let installed_hook: RecoveryCaptureHook = Box::new(move || { + let _ = fs::write(hook_written, "must be retained\n"); + }); + RECOVERY_INSTALLED_CAPTURE_HOOKS + .lock() + .map_err(|_| "installed-capture hook lock poisoned")? + .push((root.clone(), installed_hook)); let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) @@ -7953,7 +8273,7 @@ mod tests { assert!( error .to_string() - .contains("post-exchange generation was retained") + .contains("rolled-back generation was retained") ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); assert!(!written.exists()); diff --git a/docs/guardrails.md b/docs/guardrails.md index 9bb0292..66a0874 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,9 +121,12 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -Atomic conflict recovery is currently Linux-only and requires user and mount -namespaces plus same-filesystem atomic directory exchange. Unsupported or -restricted hosts block recovery before changing repository state. +Atomic conflict recovery is currently Linux-only and requires user, mount, and +PID namespaces plus same-filesystem atomic directory exchange. Recovery ignores +system and global Git configuration, rejects external config includes, and +retains one prior repository generation until the user explicitly moves or +deletes it. Unsupported or restricted hosts block recovery before changing +repository state. ## Confirmation Copy From d032e394d0e13f07fc63a243c692c41793986bd8 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 14:08:27 -0400 Subject: [PATCH 26/53] fix: close recovery review races --- crates/bitbygit-git/src/lib.rs | 556 ++++++++++++++++++++++++++++++++- crates/bitbygit-tui/src/lib.rs | 26 +- 2 files changed, 563 insertions(+), 19 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 2199a3e..bb1a406 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -931,18 +931,40 @@ impl Git { ], capture, )?; + ensure_recovery_path_has_no_symlink_ancestors( + git_dir, + Path::new("info/attributes"), + "repository attributes", + false, + )?; + let attributes = snapshot_recovery_paths( + [( + PathBuf::from("info/attributes"), + git_dir.join("info/attributes"), + )], + capture, + )?; let hooks_path = path_from_bytes(strip_byte_line_ending(&capture.required( self, &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], )?)); let hooks_location = match hooks_path.strip_prefix(root) { - Ok(relative) => RecoveryHookLocation::Repository(relative.to_owned()), + Ok(relative) => { + ensure_recovery_path_has_no_symlink_ancestors( + root, + relative, + "repository hook directory", + true, + )?; + RecoveryHookLocation::Repository(relative.to_owned()) + } Err(_) => RecoveryHookLocation::External(hooks_path.clone()), }; let hooks = snapshot_recovery_paths([(PathBuf::from("hooks"), hooks_path)], capture)?; Ok(RecoveryExecutionState { config, config_files, + attributes, hooks_location, hooks, }) @@ -1744,6 +1766,18 @@ impl RecoveryTransaction { )); } run_recovery_execution_hook(&self.root); + let mut final_capture = RecoveryCapture::default(); + let candidate_execution = candidate_git.recovery_execution_state( + &candidate_root, + &candidate_git_dir, + &mut final_capture, + )?; + let live_state = git.recovery_state()?; + if live_state != *expected_state || candidate_execution != expected_state.execution { + return Err(recovery_transaction_blocked( + "repository state, configuration, attributes, or hooks changed before isolated recovery execution", + )); + } let mut command = Command::new("unshare"); command .args([ @@ -2384,6 +2418,53 @@ fn remove_recovery_directory(path: &Path, expected_identity: (u64, u64)) -> Resu Ok(()) } +#[cfg(target_os = "linux")] +fn remove_recovery_file(path: &Path, expected_identity: (u64, u64)) -> Result<(), GitError> { + use rustix::fs::{CWD, FileType, Mode, OFlags, openat}; + + let parent = path.parent().ok_or_else(|| { + recovery_transaction_blocked("recovery cleanup file has no parent directory") + })?; + let name = path + .file_name() + .ok_or_else(|| recovery_transaction_blocked("recovery cleanup file has no file name"))?; + let name = CString::new(name.as_encoded_bytes()) + .map_err(|_| recovery_transaction_blocked("recovery cleanup file has an invalid name"))?; + let parent_fd = openat( + CWD, + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| recovery_cleanup_error("open recovery file cleanup parent", source))?; + let entry = match openat( + &parent_fd, + name.as_c_str(), + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(entry) => entry, + Err(rustix::io::Errno::NOENT) => return Ok(()), + Err(source) => { + return Err(recovery_cleanup_error("open recovery cleanup file", source)); + } + }; + let metadata = rustix::fs::fstat(&entry) + .map_err(|source| recovery_cleanup_error("inspect recovery cleanup file", source))?; + if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile + || recovery_fd_identity(&entry)? != expected_identity + { + return Err(recovery_transaction_blocked( + "recovery cleanup file identity changed; refusing to remove it", + )); + } + let mount_id = recovery_fd_mount_id(&entry)?; + ensure_recovery_cleanup_entry(&parent_fd, name.as_c_str(), expected_identity, mount_id)?; + rustix::fs::unlinkat(&parent_fd, name.as_c_str(), rustix::fs::AtFlags::empty()) + .map_err(|source| recovery_cleanup_error("remove recovery cleanup file", source))?; + Ok(()) +} + #[cfg(target_os = "linux")] fn remove_recovery_directory_contents( directory: &impl std::os::fd::AsFd, @@ -2570,6 +2651,13 @@ fn remove_recovery_directory(_path: &Path, _expected_identity: (u64, u64)) -> Re )) } +#[cfg(not(target_os = "linux"))] +fn remove_recovery_file(_path: &Path, _expected_identity: (u64, u64)) -> Result<(), GitError> { + Err(recovery_transaction_blocked( + "safe recovery file cleanup is not supported on this platform", + )) +} + #[cfg(target_os = "linux")] fn recovery_inode_flags( path: &Path, @@ -2791,6 +2879,8 @@ fn recovery_backup_pointer_path(root: &Path) -> Result { struct RecoveryBackup { path: PathBuf, + owner_identity: (u64, u64), + pointer_identity: (u64, u64), } fn recovery_backup_record_from_pointer( @@ -2843,12 +2933,14 @@ fn recovery_backup_record_from_pointer( .as_ref() .is_err_and(|source| source.kind() == std::io::ErrorKind::NotFound); let owner = recovery_candidate_owner_path(&backup); - let valid_owner = fs::symlink_metadata(&owner) + let owner_metadata = fs::symlink_metadata(&owner); + let valid_owner = owner_metadata + .as_ref() .ok() .and_then(|metadata| { read_recovery_sidecar( &owner, - &metadata, + metadata, RECOVERY_BACKUP_IDENTITY_BYTES as u64, Some(RECOVERY_BACKUP_IDENTITY_BYTES as u64), ) @@ -2862,7 +2954,16 @@ fn recovery_backup_record_from_pointer( { return Err(invalid_recovery_backup_pointer()); } - Ok(Some(RecoveryBackup { path: backup })) + let owner_identity = recovery_file_identity( + owner_metadata + .as_ref() + .map_err(|_| invalid_recovery_backup_pointer())?, + ); + Ok(Some(RecoveryBackup { + path: backup, + owner_identity, + pointer_identity: recovery_file_identity(&pointer_metadata), + })) } #[cfg(test)] @@ -2986,8 +3087,14 @@ fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { backup.path.display() ))); } - fs::remove_file(recovery_backup_pointer_path(root)?) - .map_err(|source| recovery_transaction_io("remove the previous backup pointer", source))?; + remove_recovery_file( + &recovery_candidate_owner_path(&backup.path), + backup.owner_identity, + )?; + remove_recovery_file( + &recovery_backup_pointer_path(root)?, + backup.pointer_identity, + )?; Ok(()) } @@ -3068,14 +3175,15 @@ fn append_recovery_backup_notice(output: &mut String, backup: &Path) { #[cfg(target_os = "linux")] fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> { - let source = create_recovery_probe_directory(parent)?; - let target = match create_recovery_probe_directory(parent) { + let (source, source_identity) = create_recovery_probe_directory(parent)?; + let (target, target_identity) = match create_recovery_probe_directory(parent) { Ok(target) => target, Err(error) => { - let _ = fs::remove_dir(&source); + let _ = remove_recovery_directory(&source, source_identity); return Err(error); } }; + let mut exchanged = false; let result = (|| { let _mount_id = recovery_mount_id(&source, Path::new("capability probe"))?; let output = Command::new("unshare") @@ -3113,11 +3221,28 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> recovery_capability_unavailable(format!( "same-filesystem atomic directory exchange is not available: {error}" )) - }) + })?; + exchanged = true; + Ok(()) })(); - let _ = fs::remove_dir_all(&source); - let _ = fs::remove_dir_all(&target); - result + run_recovery_probe_cleanup_hook(parent); + let source_cleanup = remove_recovery_directory( + &source, + if exchanged { + target_identity + } else { + source_identity + }, + ); + let target_cleanup = remove_recovery_directory( + &target, + if exchanged { + source_identity + } else { + target_identity + }, + ); + result.and(source_cleanup).and(target_cleanup) } #[cfg(not(target_os = "linux"))] @@ -3127,7 +3252,7 @@ fn ensure_recovery_platform_capabilities(_parent: &Path) -> Result<(), GitError> )) } -fn create_recovery_probe_directory(parent: &Path) -> Result { +fn create_recovery_probe_directory(parent: &Path) -> Result<(PathBuf, (u64, u64)), GitError> { for attempt in 0..100_u32 { let candidate = parent.join(format!( ".bitbygit-recovery-capability-{}-{}-{attempt}", @@ -3138,7 +3263,10 @@ fn create_recovery_probe_directory(parent: &Path) -> Result { .as_nanos() )); match fs::create_dir(&candidate) { - Ok(()) => return Ok(candidate), + Ok(()) => { + let identity = recovery_path_identity(&candidate)?; + return Ok((candidate, identity)); + } Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(source) => { return Err(recovery_capability_unavailable(format!( @@ -3204,6 +3332,23 @@ fn run_recovery_capability_hook(_root: &Path) -> Result<(), GitError> { Ok(()) } +#[cfg(test)] +static RECOVERY_PROBE_CLEANUP_HOOKS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn run_recovery_probe_cleanup_hook(parent: &Path) { + if let Ok(mut hooks) = RECOVERY_PROBE_CLEANUP_HOOKS.lock() + && let Some(index) = hooks.iter().position(|(target, _)| target == parent) + { + let (_, hook) = hooks.swap_remove(index); + hook(); + } +} + +#[cfg(not(test))] +fn run_recovery_probe_cleanup_hook(_parent: &Path) {} + #[cfg(test)] static RECOVERY_PREPARE_HOOKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -4170,6 +4315,104 @@ fn snapshot_recovery_paths( Ok(entries) } +#[cfg(target_os = "linux")] +fn ensure_recovery_path_has_no_symlink_ancestors( + root: &Path, + relative: &Path, + description: &str, + final_must_be_directory: bool, +) -> Result<(), GitError> { + use rustix::fs::{CWD, FileType, Mode, OFlags, openat}; + + let mut directory = openat( + CWD, + root, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|source| recovery_cleanup_error("open recovery path root", source))?; + let mut checked = PathBuf::new(); + let mut components = relative.components().peekable(); + while let Some(component) = components.next() { + let std::path::Component::Normal(name) = component else { + return Err(recovery_transaction_blocked(format!( + "atomic recovery requires {description} to be beneath the repository" + ))); + }; + checked.push(name); + let name = CString::new(name.as_encoded_bytes()).map_err(|_| { + recovery_transaction_blocked(format!( + "atomic recovery {description} contains an invalid path component" + )) + })?; + let entry = match openat( + &directory, + name.as_c_str(), + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(entry) => entry, + Err(rustix::io::Errno::NOENT) => return Ok(()), + Err(source) => { + return Err(recovery_cleanup_error( + "inspect recovery path component", + source, + )); + } + }; + let metadata = rustix::fs::fstat(&entry) + .map_err(|source| recovery_cleanup_error("inspect recovery path component", source))?; + let file_type = FileType::from_raw_mode(metadata.st_mode); + let is_final = components.peek().is_none(); + if file_type != FileType::Directory + && (!is_final || final_must_be_directory || file_type != FileType::RegularFile) + { + return Err(recovery_transaction_blocked(format!( + "atomic recovery rejects {description} with symlinked or non-directory component {}", + checked.display() + ))); + } + if file_type == FileType::Directory { + directory = entry; + } + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_recovery_path_has_no_symlink_ancestors( + root: &Path, + relative: &Path, + description: &str, + final_must_be_directory: bool, +) -> Result<(), GitError> { + let mut path = root.to_owned(); + let mut components = relative.components().peekable(); + while let Some(component) = components.next() { + let std::path::Component::Normal(name) = component else { + return Err(recovery_transaction_blocked(format!( + "atomic recovery requires {description} to be beneath the repository" + ))); + }; + path.push(name); + match fs::symlink_metadata(&path) { + Ok(metadata) + if metadata.file_type().is_dir() + || (components.peek().is_none() + && !final_must_be_directory + && metadata.file_type().is_file()) => {} + Ok(_) => { + return Err(recovery_transaction_blocked(format!( + "atomic recovery rejects {description} with a symlinked or non-directory component" + ))); + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => return Err(recovery_transaction_io("inspect recovery path", source)), + } + } + Ok(()) +} + fn recovery_metadata_io_error(path: &Path, source: std::io::Error) -> GitError { GitError::Io { args: vec![ @@ -4394,6 +4637,7 @@ pub struct RecoveryState { struct RecoveryExecutionState { config: Vec, config_files: Vec, + attributes: Vec, hooks_location: RecoveryHookLocation, hooks: Vec, } @@ -6367,9 +6611,53 @@ mod tests { git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; assert!(archived.is_dir()); + assert!(!recovery_candidate_owner_path(&first_backup).exists()); assert!(recovery_backup_from_pointer(&repo.path().canonicalize()?)?.is_some()); fs::remove_dir_all(&archived)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); + Ok(()) + } + + #[test] + fn repeated_retained_backup_cleanup_removes_owner_sidecars() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + )? { + return Ok(()); + } + let mut previous_owner: Option = None; + let mut archives = Vec::new(); + + for cycle in 0..3 { + let expected = git.recovery_state()?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + if let Some(owner) = previous_owner.take() { + assert!(!owner.exists()); + } + let root = repo.path().canonicalize()?; + let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; + let owner = recovery_candidate_owner_path(&backup); + assert!(owner.is_file()); + let archive = repo + .path() + .with_extension(format!("approved-backup-{cycle}")); + fs::rename(&backup, &archive)?; + archives.push(archive); + previous_owner = Some(owner); + if cycle < 2 { + repo.run_allow_failure(["merge", "other"])?; + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + } + } + + for archive in archives { + fs::remove_dir_all(archive)?; + } Ok(()) } @@ -6456,6 +6744,62 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn capability_probe_cleanup_rejects_synchronized_path_replacement() -> Result<(), Box> + { + use std::sync::{Arc, Mutex}; + + let parent = TempRepo::new()?; + let replaced = Arc::new(Mutex::new(None)); + let hook_replaced = Arc::clone(&replaced); + let hook_parent = parent.path(); + let hook: RecoveryCaptureHook = Box::new(move || { + let Ok(entries) = fs::read_dir(&hook_parent) else { + return; + }; + for entry in entries.flatten() { + if !entry + .file_name() + .as_encoded_bytes() + .starts_with(b".bitbygit-recovery-capability-") + { + continue; + } + let probe = entry.path(); + let displaced = probe.with_extension("displaced"); + if fs::rename(&probe, &displaced).is_ok() && fs::create_dir(&probe).is_ok() { + let _ = fs::write(probe.join("replacement-sentinel"), "preserve\n"); + if let Ok(mut paths) = hook_replaced.lock() { + *paths = Some((probe, displaced)); + } + } + return; + } + }); + RECOVERY_PROBE_CLEANUP_HOOKS + .lock() + .map_err(|_| "recovery probe cleanup hook lock poisoned")? + .push((parent.path(), hook)); + + let Err(_error) = ensure_recovery_platform_capabilities(&parent.path()) else { + return Err("expected substituted capability probe cleanup to fail closed".into()); + }; + + let (probe, displaced) = replaced + .lock() + .map_err(|_| "replaced probe lock poisoned")? + .take() + .ok_or("capability probe hook did not replace a probe")?; + assert_eq!( + fs::read_to_string(probe.join("replacement-sentinel"))?, + "preserve\n" + ); + fs::remove_dir_all(probe)?; + fs::remove_dir_all(displaced)?; + Ok(()) + } + #[test] fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() -> Result<(), Box> { @@ -7217,6 +7561,7 @@ mod tests { execution: RecoveryExecutionState { config: Vec::new(), config_files: Vec::new(), + attributes: Vec::new(), hooks_location: RecoveryHookLocation::Repository(PathBuf::new()), hooks: Vec::new(), }, @@ -7328,6 +7673,185 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_rejects_repository_hook_path_with_symlinked_parent() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let (repo, _original_head) = prepare_merge_conflict()?; + let external = repo.path().with_extension("external-hooks-parent"); + fs::create_dir(&external)?; + symlink(&external, repo.path().join("hooks"))?; + repo.run(["config", "core.hooksPath", "hooks/subdir"])?; + + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected a symlinked hook parent to block recovery".into()); + }; + + assert!( + error.to_string().contains("symlinked or non-directory") + || error + .to_string() + .contains("repository-local hook directory") + ); + assert_eq!( + Git::new(repo.path()).status()?.operation, + Some(RepositoryOperation::Merge) + ); + fs::remove_dir_all(external)?; + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn exact_recovery_rejects_hook_parent_replaced_after_final_snapshot() + -> Result<(), Box> { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + fs::create_dir_all(repo.path().join("hooks/subdir"))?; + repo.run(["config", "core.hooksPath", "hooks/subdir"])?; + let external = repo.path().with_extension("late-external-hooks"); + let marker = repo.path().with_extension("late-external-hook-ran"); + fs::create_dir(&external)?; + let external_hook = external.join("reference-transaction"); + fs::write( + &external_hook, + format!( + "#!/bin/sh\ntouch {}\n", + shell_quote(&marker.to_string_lossy()) + ), + )?; + let mut permissions = fs::metadata(&external_hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&external_hook, permissions)?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + if !recovery_capabilities_or_verify_fail_closed( + &git, + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + )? { + fs::remove_dir_all(external)?; + return Ok(()); + } + let root = repo.path().canonicalize()?; + let parent = root + .parent() + .ok_or("test repository has no parent")? + .to_owned(); + let root_identity = recovery_path_identity(&root)?; + let replacement = external.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let candidate = entry.path(); + let owner = recovery_candidate_owner_path(&candidate); + let mut expected_owner = Vec::with_capacity(16); + expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); + expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); + if !fs::read(owner).is_ok_and(|bytes| bytes.starts_with(&expected_owner)) { + continue; + } + let hooks = candidate.join("hooks"); + if fs::rename(&hooks, candidate.join("original-hooks")).is_ok() { + let _ = symlink(&replacement, hooks); + } + return; + } + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((root, hook)); + + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected the late symlinked hook parent to block recovery".into()); + }; + + assert!( + error.to_string().contains("symlinked or non-directory") + || error + .to_string() + .contains("changed before isolated recovery execution"), + "{error}" + ); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_dir_all(external)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn exact_recovery_rejects_repository_attributes_changed_after_preview() + -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let marker = repo.path().with_extension("late-filter-ran"); + repo.run_args(&[ + "config", + "filter.marker.smudge", + &format!("touch {}; cat", shell_quote(&marker.to_string_lossy())), + ])?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + fs::write( + git.git_path("info/attributes")?, + "conflict.txt filter=marker\n", + )?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed repository attributes to invalidate the preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + + #[test] + fn exact_recovery_rejects_filter_command_changed_after_preview() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + fs::write( + Git::new(repo.path()).git_path("info/attributes")?, + "conflict.txt filter=marker\n", + )?; + repo.run(["config", "filter.marker.smudge", "cat"])?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + repo.run(["config", "filter.marker.smudge", "false"])?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected a changed filter command to invalidate the preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + #[cfg(unix)] #[test] fn exact_recovery_fences_global_config_changed_after_final_capture() diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 0b20876..d2e9277 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -2253,10 +2253,10 @@ fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<() }) { return Ok(()); } - let operation = git + let status = git .status() - .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? - .operation; + .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))?; + let operation = status.operation; for request in requests { if let OperationRequest::Recover(recovery) = request @@ -2264,6 +2264,18 @@ fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<() { return Err(recovery_state_error(*recovery, operation)); } + if let OperationRequest::Recover(recovery) = request + && matches!( + recovery, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) + && !status.conflicted_files().is_empty() + { + return Err(format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(recovery.operation_label()) + )); + } if let Some(active) = operation && !conflict_mode_allows(request) { @@ -5049,6 +5061,14 @@ mod tests { let repo = merge_conflict_repo("recovery-planner")?; let planner = OperationPlanner::new(repo); + let Err(error) = planner.plan_prompt_sequence(vec![ + OperationRequest::Fetch, + OperationRequest::Recover(RecoveryRequest::MergeContinue), + ]) else { + return Err("fetch then blocked merge continue should fail sequence preflight".into()); + }; + assert!(error.contains("resolve and stage all conflicts")); + let operation = planner .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) .map_err(std::io::Error::other)?; From dd03ab34db7b549f472e98433276a487d128e441 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 14:33:57 -0400 Subject: [PATCH 27/53] fix: fail closed on unsafe recovery isolation --- .github/workflows/ci.yml | 18 +- crates/bitbygit-git/src/lib.rs | 443 ++++++++++++++++++++++++++++++--- docs/guardrails.md | 12 +- 3 files changed, 423 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd37341..20d36fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,22 +34,10 @@ jobs: - name: Test run: cargo test --locked --workspace - - name: Enable Linux recovery isolation + - name: Test fail-closed recovery capabilities run: | - if [ -e /proc/sys/kernel/unprivileged_userns_clone ]; then - sudo sysctl -w kernel.unprivileged_userns_clone=1 - fi - if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - - name: Test production recovery path - env: - BITBYGIT_REQUIRE_RECOVERY_SUCCESS: "1" - run: | - cargo test --locked -p bitbygit-git tests::exact_recovery_executes_every_supported_action -- --exact - cargo test --locked -p bitbygit-git tests::exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data -- --exact - cargo test --locked -p bitbygit-tui tests::tui_reattaches_to_promoted_repository_after_recovery -- --exact + cargo test --locked -p bitbygit-git tests::exact_recovery_fails_closed_before_creating_candidate -- --exact + cargo test --locked -p bitbygit-git tests::capability_probe_enforces_deadline_and_output_bounds -- --exact - name: Build run: cargo build --locked --workspace diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index bb1a406..821c48e 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -937,13 +937,15 @@ impl Git { "repository attributes", false, )?; - let attributes = snapshot_recovery_paths( + let mut attributes = snapshot_recovery_paths( [( PathBuf::from("info/attributes"), git_dir.join("info/attributes"), )], capture, )?; + attributes.extend(snapshot_recovery_worktree_attributes(root, capture)?); + attributes.sort_by(|left, right| left.path.cmp(&right.path)); let hooks_path = path_from_bytes(strip_byte_line_ending(&capture.required( self, &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], @@ -1789,7 +1791,7 @@ impl RecoveryTransaction { "--fork", "sh", "-c", - "mount --bind \"$1\" \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; cd \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; shift 2; exec git \"$@\"", + "mount --bind \"$1\" \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; cd \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; shift 2; exec git -c core.attributesFile=/dev/null \"$@\"", "bitbygit-recovery", ]) .arg(&self.candidate) @@ -1882,9 +1884,11 @@ impl RecoveryTransaction { run_recovery_promotion_hook(&self.root); ensure_recovery_git_storage_isolated(&self.candidate)?; let candidate_generation = RecoveryGeneration::capture_isolated(&self.candidate)?; - if recovery_path_identity(&self.candidate)? != self.candidate_identity { + if recovery_path_identity(&self.root)? != self.root_identity + || recovery_path_identity(&self.candidate)? != self.candidate_identity + { return Err(recovery_transaction_blocked( - "isolated recovery candidate changed before atomic promotion", + "repository or isolated recovery candidate changed before atomic promotion", )); } ensure_recovery_mount_tree_isolated(&self.root)?; @@ -1902,20 +1906,15 @@ impl RecoveryTransaction { || !matches!(&old_generation, Ok(generation) if generation == &self.baseline) || !matches!(&installed_generation, Ok(generation) if generation == &candidate_generation) { - if let Err(error) = atomic_exchange_directories(&self.root, &self.candidate) { - return Err(recovery_transaction_blocked(format!( - "repository changed during atomic recovery promotion and rollback failed; both complete generations were retained: {error}" - ))); - } self.keep_candidate = true; return Err(recovery_transaction_blocked( match (old_generation, installed_generation) { (Err(error), _) | (_, Err(error)) => format!( - "repository metadata changed during atomic recovery promotion; the rolled-back generation was retained at {}: {error}", + "repository metadata changed during atomic recovery promotion; no rollback was attempted through uncertain pathnames and the other exposed generation was retained at {}: {error}", self.candidate.display() ), _ => format!( - "repository changed during atomic recovery promotion; the rolled-back generation was retained at {}", + "repository changed during atomic recovery promotion; no rollback was attempted through uncertain pathnames and the other exposed generation was retained at {}", self.candidate.display() ), }, @@ -3132,6 +3131,15 @@ fn repository_local_recovery_config( origin_path.display() ))); } + let key = field[2] + .split(|byte| *byte == b'\n') + .next() + .unwrap_or_default(); + if key.eq_ignore_ascii_case(b"core.attributesfile") { + return Err(recovery_transaction_blocked( + "atomic recovery does not support core.attributesFile; user attributes are pinned off", + )); + } config.extend_from_slice(field[0]); config.push(0); config.extend_from_slice(field[2]); @@ -3184,9 +3192,10 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> } }; let mut exchanged = false; - let result = (|| { + let result: Result<(), GitError> = (|| { let _mount_id = recovery_mount_id(&source, Path::new("capability probe"))?; - let output = Command::new("unshare") + let mut command = Command::new(recovery_probe_program(parent)); + command .args([ "--user", "--map-root-user", @@ -3200,13 +3209,15 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> "bitbygit-recovery-capability", ]) .arg(&source) - .arg(&target) - .output() - .map_err(|source| { - recovery_capability_unavailable(format!( - "the Linux user/mount namespace probe could not start: {source}" - )) - })?; + .arg(&target); + let output = run_bounded_recovery_process( + &mut command, + vec!["unshare".to_owned(), "recovery-capability-probe".to_owned()], + recovery_probe_duration(parent), + "capability probe", + None, + ) + .map_err(recovery_capability_unavailable)?; if !output.status.success() { let detail = if output.stderr.is_empty() { format!("status {}", output.status) @@ -3223,7 +3234,9 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> )) })?; exchanged = true; - Ok(()) + Err(recovery_capability_unavailable( + "the platform cannot make a writable speculative repository unreachable to same-identity host processes or condition directory exchange on inode identity", + )) })(); run_recovery_probe_cleanup_hook(parent); let source_cleanup = remove_recovery_directory( @@ -3245,6 +3258,44 @@ fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> result.and(source_cleanup).and(target_cleanup) } +#[cfg(test)] +static RECOVERY_PROBE_PROGRAMS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_probe_program(parent: &Path) -> OsString { + if let Ok(mut programs) = RECOVERY_PROBE_PROGRAMS.lock() + && let Some(index) = programs.iter().position(|(target, _)| target == parent) + { + return programs.swap_remove(index).1; + } + OsString::from("unshare") +} + +#[cfg(not(test))] +fn recovery_probe_program(_parent: &Path) -> OsString { + OsString::from("unshare") +} + +#[cfg(test)] +static RECOVERY_PROBE_DURATIONS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(test)] +fn recovery_probe_duration(parent: &Path) -> std::time::Duration { + if let Ok(mut durations) = RECOVERY_PROBE_DURATIONS.lock() + && let Some(index) = durations.iter().position(|(target, _)| target == parent) + { + return durations.swap_remove(index).1; + } + MAX_RECOVERY_COMMAND_DURATION +} + +#[cfg(not(test))] +fn recovery_probe_duration(_parent: &Path) -> std::time::Duration { + MAX_RECOVERY_COMMAND_DURATION +} + #[cfg(not(target_os = "linux"))] fn ensure_recovery_platform_capabilities(_parent: &Path) -> Result<(), GitError> { Err(recovery_capability_unavailable( @@ -4222,6 +4273,45 @@ fn snapshot_recovery_metadata( ) } +fn snapshot_recovery_worktree_attributes( + root: &Path, + capture: &mut RecoveryCapture, +) -> Result, GitError> { + let mut pending = vec![PathBuf::new()]; + let mut paths = Vec::new(); + let mut entries = 0_usize; + let mut path_bytes = 0_usize; + while let Some(relative) = pending.pop() { + for child in fs::read_dir(root.join(&relative)) + .map_err(|source| recovery_metadata_io_error(&relative, source))? + { + let child = child.map_err(|source| recovery_metadata_io_error(&relative, source))?; + let child_relative = relative.join(child.file_name()); + if child_relative == Path::new(".git") { + continue; + } + entries = entries.saturating_add(1); + path_bytes = + path_bytes.saturating_add(child_relative.as_os_str().as_encoded_bytes().len()); + if entries > MAX_RECOVERY_GENERATION_ENTRIES + || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES + { + return Err(recovery_bound_error( + "attribute source traversal exceeds repository entry or path bounds".to_owned(), + )); + } + let metadata = fs::symlink_metadata(child.path()) + .map_err(|source| recovery_metadata_io_error(&child_relative, source))?; + if metadata.file_type().is_dir() { + pending.push(child_relative); + } else if child.file_name() == ".gitattributes" { + paths.push((Path::new("worktree").join(&child_relative), child.path())); + } + } + } + snapshot_recovery_paths(paths, capture) +} + fn snapshot_recovery_paths( paths: impl IntoIterator, capture: &mut RecoveryCapture, @@ -6800,6 +6890,93 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn capability_probe_enforces_deadline_and_output_bounds() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + for (name, body, expected) in [ + ( + "quiet", + "#!/bin/sh\nsleep 30\n", + "capability probe exceeded", + ), + ( + "noisy", + "#!/bin/sh\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\n", + "capability probe output bytes exceed", + ), + ] { + let parent = TempRepo::new()?; + let script = parent.path().with_extension(format!("{name}-probe")); + fs::write(&script, body)?; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755))?; + RECOVERY_PROBE_PROGRAMS + .lock() + .map_err(|_| "recovery probe program lock poisoned")? + .push((parent.path(), script.clone().into_os_string())); + RECOVERY_PROBE_DURATIONS + .lock() + .map_err(|_| "recovery probe duration lock poisoned")? + .push((parent.path(), std::time::Duration::from_millis(250))); + + let started = std::time::Instant::now(); + let Err(error) = ensure_recovery_platform_capabilities(&parent.path()) else { + return Err(format!("expected {name} capability probe to be bounded").into()); + }; + + assert!(error.to_string().contains(expected), "{error}"); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + assert!(fs::read_dir(parent.path())?.next().is_none()); + fs::remove_file(script)?; + } + Ok(()) + } + + #[test] + fn exact_recovery_fails_closed_before_creating_candidate() -> Result<(), Box> { + use std::sync::{Arc, atomic::AtomicBool}; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let root = repo.path().canonicalize()?; + let before = RecoveryGeneration::capture(&root)?; + let executed = Arc::new(AtomicBool::new(false)); + let hook_executed = Arc::clone(&executed); + let hook: RecoveryCaptureHook = Box::new(move || { + hook_executed.store(true, Ordering::Release); + }); + RECOVERY_EXECUTION_HOOKS + .lock() + .map_err(|_| "recovery execution hook lock poisoned")? + .push((root.clone(), hook)); + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected speculative recovery to fail closed".into()); + }; + + assert!( + matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), + "{error}" + ); + assert!(!executed.load(Ordering::Acquire)); + assert_eq!(RecoveryGeneration::capture(&root)?, before); + assert_eq!(git.recovery_state()?, expected); + let parent = root.parent().ok_or("test repository has no parent")?; + assert!(fs::read_dir(parent)?.all(|entry| { + entry.is_ok_and(|entry| { + !entry + .file_name() + .as_encoded_bytes() + .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) + }) + })); + Ok(()) + } + #[test] fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() -> Result<(), Box> { @@ -6954,23 +7131,19 @@ mod tests { } guard.unmount()?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; let root = repo.path().canonicalize()?; - let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; - repo.run_allow_failure(["merge", "other"])?; - let expected = git.recovery_state()?; - + let before = RecoveryGeneration::capture(&root)?; let Err(error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) else { - return Err("retained backup was not blocked before later recovery".into()); + return Err("expected speculative recovery to remain unavailable".into()); }; assert!( - error.to_string().contains("inspect and move or delete it"), + matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), "{error}" ); assert_eq!(git.recovery_state()?, expected); - assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup)); + assert_eq!(RecoveryGeneration::capture(&root)?, before); assert_eq!( fs::read_to_string(source.join("sentinel"))?, "must survive recovery\n" @@ -7454,6 +7627,174 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_does_not_exchange_a_synchronized_root_replacement() + -> Result<(), Box> { + let (mut repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let root = repo.path().canonicalize()?; + let parent = root.parent().ok_or("test repository has no parent")?; + let baseline = RecoveryGeneration::capture(&root)?; + let root_identity = recovery_path_identity(&root)?; + let (candidate, _) = create_recovery_candidate(parent, &root)?; + let candidate_identity = recovery_path_identity(&candidate)?; + let mut transaction = RecoveryTransaction { + root: root.clone(), + backup_pointer: recovery_backup_pointer_path(&candidate)?, + candidate, + baseline, + root_identity, + candidate_identity, + keep_candidate: false, + }; + transaction.copy_repository()?; + let displaced = root.with_extension("promotion-displaced"); + let hook_root = root.clone(); + let hook_displaced = displaced.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + if fs::rename(&hook_root, &hook_displaced).is_ok() && fs::create_dir(&hook_root).is_ok() + { + let _ = fs::write(hook_root.join("replacement-sentinel"), "preserve\n"); + } + }); + RECOVERY_PROMOTION_HOOKS + .lock() + .map_err(|_| "recovery promotion hook lock poisoned")? + .push((root.clone(), hook)); + + let Err(error) = transaction.promote(&git, &expected) else { + return Err("expected synchronized root replacement to block promotion".into()); + }; + + assert!( + error + .to_string() + .contains("changed before atomic promotion") + ); + assert_eq!( + fs::read_to_string(root.join("replacement-sentinel"))?, + "preserve\n" + ); + fs::remove_dir_all(&root)?; + fs::rename(&displaced, &root)?; + repo.path = root; + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_does_not_exchange_a_synchronized_candidate_replacement() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let root = repo.path().canonicalize()?; + let parent = root.parent().ok_or("test repository has no parent")?; + let baseline = RecoveryGeneration::capture(&root)?; + let root_identity = recovery_path_identity(&root)?; + let (candidate, _) = create_recovery_candidate(parent, &root)?; + let candidate_identity = recovery_path_identity(&candidate)?; + let mut transaction = RecoveryTransaction { + root: root.clone(), + backup_pointer: recovery_backup_pointer_path(&candidate)?, + candidate: candidate.clone(), + baseline, + root_identity, + candidate_identity, + keep_candidate: false, + }; + transaction.copy_repository()?; + let displaced = candidate.with_extension("promotion-displaced"); + let hook_candidate = candidate.clone(); + let hook_displaced = displaced.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + if fs::rename(&hook_candidate, &hook_displaced).is_ok() + && fs::create_dir(&hook_candidate).is_ok() + && fs::create_dir(hook_candidate.join(".git")).is_ok() + { + let _ = fs::write(hook_candidate.join("replacement-sentinel"), "preserve\n"); + } + }); + RECOVERY_PROMOTION_HOOKS + .lock() + .map_err(|_| "recovery promotion hook lock poisoned")? + .push((root, hook)); + + let Err(error) = transaction.promote(&git, &expected) else { + return Err("expected synchronized candidate replacement to block promotion".into()); + }; + + assert!( + error + .to_string() + .contains("changed before atomic promotion") + ); + assert_eq!( + fs::read_to_string(candidate.join("replacement-sentinel"))?, + "preserve\n" + ); + fs::remove_dir_all(&candidate)?; + fs::remove_dir_all(&displaced)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn atomic_promotion_never_rolls_back_through_a_replaced_root() -> Result<(), Box> { + let (mut repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + let root = repo.path().canonicalize()?; + let parent = root.parent().ok_or("test repository has no parent")?; + let baseline = RecoveryGeneration::capture(&root)?; + let root_identity = recovery_path_identity(&root)?; + let (candidate, _) = create_recovery_candidate(parent, &root)?; + let candidate_identity = recovery_path_identity(&candidate)?; + let mut transaction = RecoveryTransaction { + root: root.clone(), + backup_pointer: recovery_backup_pointer_path(&candidate)?, + candidate: candidate.clone(), + baseline, + root_identity, + candidate_identity, + keep_candidate: false, + }; + transaction.copy_repository()?; + let installed = root.with_extension("installed-recovery"); + let hook_root = root.clone(); + let hook_installed = installed.clone(); + let hook: RecoveryCaptureHook = Box::new(move || { + if fs::rename(&hook_root, &hook_installed).is_ok() && fs::create_dir(&hook_root).is_ok() + { + let _ = fs::write(hook_root.join("replacement-sentinel"), "preserve\n"); + } + }); + RECOVERY_POST_EXCHANGE_HOOKS + .lock() + .map_err(|_| "recovery post-exchange hook lock poisoned")? + .push((root.clone(), hook)); + + let Err(error) = transaction.promote(&git, &expected) else { + return Err("expected post-exchange root replacement to block promotion".into()); + }; + + assert!(error.to_string().contains("no rollback was attempted")); + assert_eq!( + fs::read_to_string(root.join("replacement-sentinel"))?, + "preserve\n" + ); + assert_eq!(recovery_path_identity(&candidate)?, root_identity); + fs::remove_dir_all(&root)?; + fs::rename(&candidate, &root)?; + fs::remove_dir_all(installed)?; + let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); + repo.path = root; + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn recovery_cleanup_rejects_synchronized_path_replacement() -> Result<(), Box> { @@ -7827,6 +8168,50 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_untracked_worktree_attributes_added_after_preview() + -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()); + let expected = git.recovery_state()?; + fs::write( + repo.path().join(".gitattributes"), + "conflict.txt filter=late\n", + )?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected untracked worktree attributes to invalidate the preview".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + + #[test] + fn recovery_rejects_configured_user_attributes_file() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let attributes = repo.path().with_extension("external-attributes"); + fs::write(&attributes, "conflict.txt filter=external\n")?; + repo.run_args(&[ + "config", + "core.attributesFile", + attributes.to_string_lossy().as_ref(), + ])?; + + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected configured user attributes to block recovery preview".into()); + }; + + assert!(error.to_string().contains("core.attributesFile"), "{error}"); + fs::remove_file(attributes)?; + Ok(()) + } + #[test] fn exact_recovery_rejects_filter_command_changed_after_preview() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; diff --git a/docs/guardrails.md b/docs/guardrails.md index 66a0874..e9d2111 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,12 +121,12 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -Atomic conflict recovery is currently Linux-only and requires user, mount, and -PID namespaces plus same-filesystem atomic directory exchange. Recovery ignores -system and global Git configuration, rejects external config includes, and -retains one prior repository generation until the user explicitly moves or -deletes it. Unsupported or restricted hosts block recovery before changing -repository state. +Atomic conflict recovery remains blocked on current platforms. User and mount +namespaces do not prevent another process with the same host identity from +writing a speculative repository, and Linux directory exchange cannot be +conditioned on inode identity. Recovery stays unavailable until both guarantees +can be enforced; capability probes are bounded and fail before a candidate is +created or repository state changes. ## Confirmation Copy From 2fd6643e9aab8148b2064859863d5d6f3161c709 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:03:01 -0400 Subject: [PATCH 28/53] fix: simplify conflict recovery execution --- .github/workflows/ci.yml | 5 - Cargo.lock | 110 +- README.md | 7 - crates/bitbygit-git/Cargo.toml | 10 - crates/bitbygit-git/src/lib.rs | 8889 ++++---------------------------- crates/bitbygit-tui/src/lib.rs | 173 +- docs/guardrails.md | 6 - 7 files changed, 1011 insertions(+), 8189 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2084839..ac0463a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,10 +47,5 @@ jobs: - name: Test run: cargo test --locked --workspace - - name: Test fail-closed recovery capabilities - run: | - cargo test --locked -p bitbygit-git tests::exact_recovery_fails_closed_before_creating_candidate -- --exact - cargo test --locked -p bitbygit-git tests::capability_probe_enforces_deadline_and_output_bounds -- --exact - - name: Build run: cargo build --locked --workspace diff --git a/Cargo.lock b/Cargo.lock index 7818e7c..155a9df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,12 +36,6 @@ dependencies = [ [[package]] name = "bitbygit-git" version = "0.1.0" -dependencies = [ - "libc", - "rustix 0.38.44", - "sha2", - "xattr", -] [[package]] name = "bitbygit-store" @@ -69,15 +63,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "cassowary" version = "0.3.0" @@ -113,15 +98,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crossterm" version = "0.28.1" @@ -132,7 +108,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -147,16 +123,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.20.11" @@ -192,16 +158,6 @@ dependencies = [ "syn", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "either" version = "1.16.0" @@ -236,16 +192,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -334,12 +280,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "lock_api" version = "0.4.14" @@ -468,23 +408,10 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -555,17 +482,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -689,12 +605,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -730,12 +640,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -861,16 +765,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index 77df113..32bf706 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,6 @@ Prerequisites: - `git` available on `PATH`. - `gh` is optional for future GitHub workflows. -Conflict recovery is supported on Linux hosts that provide unprivileged user, -mount, and PID namespaces, bind mounts, and same-filesystem atomic directory -exchange. It fails closed without changing the repository on other platforms -or when those Linux capabilities are restricted. Recovery retains one previous -repository generation; inspect and move or delete the reported directory before -running another recovery. - Run local checks: ```sh diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 97336c0..9867516 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -8,13 +8,3 @@ rust-version.workspace = true [lints] workspace = true - -[dependencies] -sha2 = "0.10" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[target.'cfg(target_os = "linux")'.dependencies] -rustix = { version = "0.38", features = ["fs", "process"] } -xattr = "1" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 7a565b4..c242b49 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1,19 +1,14 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::env; use std::error::Error; use std::ffi::OsString; -#[cfg(target_os = "linux")] -use std::ffi::{CStr, CString}; use std::fmt::{self, Display, Formatter}; use std::fs; -use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; +use std::process::{Command, ExitStatus}; use std::string::FromUtf8Error; use std::sync::atomic::{AtomicU64, Ordering}; -use sha2::{Digest, Sha256}; - #[cfg(unix)] use std::os::unix::ffi::OsStringExt; @@ -26,55 +21,6 @@ const COMMIT_HOOKS: &[&str] = &[ "commit-msg", "post-commit", ]; -const RECOVERY_METADATA_PATHS: &[&str] = &[ - "objects/info/alternates", - "MERGE_HEAD", - "MERGE_MSG", - "MERGE_MODE", - "MERGE_AUTOSTASH", - "AUTO_MERGE", - "ORIG_HEAD", - "REBASE_HEAD", - "rebase-merge", - "rebase-apply", -]; -const REBASE_PROGRESS_PATHS: &[&str] = &["rebase-merge/msgnum", "rebase-apply/next"]; -const MAX_RECOVERY_RETAINED_BYTES: usize = 8 * 1024 * 1024; -const MAX_RECOVERY_OUTPUT_BYTES: usize = 4 * 1024 * 1024; -const MAX_RECOVERY_FILE_BYTES_READ: u64 = 64 * 1024 * 1024; -const MAX_RECOVERY_RELEVANT_FILES: usize = 10_000; -const MAX_RECOVERY_PATH_BYTES: usize = 256 * 1024; -const MAX_RECOVERY_METADATA_BYTES: usize = 4 * 1024 * 1024; -const MAX_RECOVERY_METADATA_ENTRIES: usize = 4_096; -const MAX_RECOVERY_SUBPROCESSES: usize = 12; -const MAX_RECOVERY_COMMAND_DURATION: std::time::Duration = std::time::Duration::from_secs(30); -const MAX_RECOVERY_GENERATION_ENTRIES: usize = 100_000; -const MAX_RECOVERY_GENERATION_PATH_BYTES: usize = 4 * 1024 * 1024; -const MAX_RECOVERY_GENERATION_FILE_BYTES: u64 = 1024 * 1024 * 1024; -const MAX_RECOVERY_BACKUP_POINTER_BYTES: u64 = 16 * 1024; -const RECOVERY_BACKUP_IDENTITY_BYTES: usize = 64; -const RECOVERY_GENERATION_DIGEST_BYTES: usize = 32; -const RECOVERY_BACKUP_POINTER: &str = "bitbygit-recovery-backup.pointer"; -const RECOVERY_CANDIDATE_PREFIX: &str = ".bitbygit-recovery-candidate-"; -const RECOVERY_GIT_ENVIRONMENT: &[&str] = &[ - "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_COMMON_DIR", - "GIT_DIR", - "GIT_GRAFT_FILE", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_QUARANTINE_PATH", - "GIT_SHALLOW_FILE", - "GIT_WORK_TREE", -]; -const RECOVERY_CONFIG_ENVIRONMENT: &[&str] = &[ - "GIT_CONFIG_COUNT", - "GIT_CONFIG_GLOBAL", - "GIT_CONFIG_PARAMETERS", - "GIT_CONFIG_SYSTEM", -]; -const RECOVERY_CAPABILITY_UNAVAILABLE: &str = - "atomic recovery is unavailable because required platform capabilities are missing"; static NEXT_STAGED_FETCH_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone)] @@ -264,8 +210,7 @@ impl Git { self.run_args(vec!["rebase".to_owned(), base.oid.clone()]) } - #[cfg(test)] - fn recover( + pub fn recover( &self, operation: RepositoryOperation, action: RecoveryAction, @@ -315,15 +260,27 @@ impl Git { ]) } + pub fn recovery_state(&self) -> Result { + Ok(RecoveryState { + operation: self.repository_operation()?, + head: self.head_target()?, + status: self + .run_raw(["status", "--porcelain=v2", "--branch", "-z"])? + .stdout, + index: self.run_raw(["ls-files", "--stage", "-z"])?.stdout, + worktree: self + .run_raw(["diff", "--binary", "--no-ext-diff", "--full-index", "--"])? + .stdout, + }) + } + pub fn recover_exact( &self, operation: RepositoryOperation, action: RecoveryAction, expected_state: &RecoveryState, ) -> Result { - ensure_recovery_environment_isolated()?; - let current_state = self.recovery_state()?; - if current_state != *expected_state { + if self.recovery_state()? != *expected_state { return Err(GitError::Blocked { message: format!( "{} {} is blocked because repository state changed after preview", @@ -332,44 +289,7 @@ impl Git { ), }); } - if !expected_state.execution.hooks_are_repository_local() { - return Err(recovery_transaction_blocked( - "atomic recovery requires a repository-local hook directory without symlinked hooks", - )); - } - let mut transaction = RecoveryTransaction::prepare(self)?; - let result = transaction.run_recovery( - self, - vec![ - operation.label().to_owned(), - format!("--{}", action.label()), - ], - expected_state, - ); - match result { - Ok(mut output) => { - let backup = transaction.promote(self, ¤t_state)?; - append_recovery_backup_notice(&mut output.stderr, &backup); - Ok(output) - } - Err(mut error) - if operation == RepositoryOperation::Rebase - && matches!(action, RecoveryAction::Continue | RecoveryAction::Skip) - && transaction.has_advanced_rebase_conflict(¤t_state) => - { - let backup = transaction.promote(self, ¤t_state)?; - if let GitError::GitFailed { stderr, .. } = &mut error { - append_recovery_backup_notice(stderr, &backup); - } - Err(error) - } - Err(error) => Err(error), - } - } - - pub fn ensure_recovery_supported(&self) -> Result<(), GitError> { - ensure_recovery_environment_isolated()?; - RecoveryTransaction::supported_root(self).map(|_| ()) + self.recover(operation, action) } pub fn push_current_branch( @@ -872,340 +792,6 @@ impl Git { Ok(status) } - pub fn recovery_state(&self) -> Result { - let mut capture = RecoveryCapture::default(); - let git_dir = path_from_bytes(strip_byte_line_ending( - &capture.required(self, &["rev-parse", "--absolute-git-dir"])?, - )); - let root = path_from_bytes(strip_byte_line_ending( - &capture.required(self, &["rev-parse", "--show-toplevel"])?, - )); - let operation = recovery_operation_at(&git_dir); - let head_oid = capture - .optional(self, &["rev-parse", "--verify", "--quiet", "HEAD"])? - .map(|output| String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned()); - let head_reference = capture - .optional(self, &["symbolic-ref", "--quiet", "HEAD"])? - .map(|output| String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned()); - let metadata = self.recovery_metadata(&git_dir, &mut capture)?; - let original_head = match operation { - Some(RepositoryOperation::Rebase) => Some(rebase_original_head(&metadata)?), - Some(RepositoryOperation::Merge) => capture - .optional(self, &["rev-parse", "--verify", "--quiet", "ORIG_HEAD"])? - .map(|output| { - String::from_utf8_lossy(strip_byte_line_ending(&output)).into_owned() - }), - None => None, - }; - let mut paths = BTreeSet::new(); - let changed_paths = - capture.required(self, &["diff", "--raw", "-z", "--no-abbrev", "HEAD", "--"])?; - parse_recovery_changed_paths(&changed_paths, &mut paths)?; - if let Some(original_head) = original_head { - let destination_paths = capture.required( - self, - &[ - "diff", - "--raw", - "-z", - "--no-abbrev", - "HEAD", - &original_head, - "--", - ], - )?; - parse_recovery_changed_paths(&destination_paths, &mut paths)?; - if operation == Some(RepositoryOperation::Rebase) { - let range = format!("{}..{original_head}", head_oid.as_deref().unwrap_or("HEAD")); - let changed_paths = capture.required( - self, - &[ - "log", - "--format=%x00", - "--raw", - "-z", - "--no-abbrev", - "--diff-merges=first-parent", - &range, - "--", - ], - )?; - parse_recovery_changed_paths(&changed_paths, &mut paths)?; - } - } - let path_bytes = paths - .iter() - .map(|path| path.as_os_str().as_encoded_bytes().len()) - .sum::(); - if path_bytes > MAX_RECOVERY_PATH_BYTES { - return Err(recovery_bound_error(format!( - "relevant path bytes exceed {MAX_RECOVERY_PATH_BYTES}" - ))); - } - capture.retain( - path_bytes.saturating_add(paths.len() * 96), - "relevant paths", - )?; - - let mut index_args = vec![ - OsString::from("ls-files"), - OsString::from("--stage"), - OsString::from("-z"), - OsString::from("--"), - ]; - index_args.extend(paths.iter().map(|path| path.as_os_str().to_owned())); - let index = capture.required_os(self, index_args)?; - capture.retain(index.len(), "index output")?; - - let worktree = self.snapshot_recovery_worktree(&root, paths, &mut capture)?; - run_recovery_capture_hook(&self.cwd); - let refs = self.recovery_refs(operation, &metadata, &mut capture)?; - capture.retain(refs.len(), "recovery refs")?; - let execution = self.recovery_execution_state(&root, &git_dir, &mut capture)?; - - Ok(RecoveryState { - operation, - head: HeadTarget { - oid: head_oid, - reference: head_reference, - }, - index, - worktree, - metadata, - refs, - execution, - }) - } - - fn recovery_execution_state( - &self, - root: &Path, - git_dir: &Path, - capture: &mut RecoveryCapture, - ) -> Result { - let config = capture.required( - self, - &[ - "config", - "--null", - "--show-scope", - "--show-origin", - "--list", - ], - )?; - let config = repository_local_recovery_config(config, root, git_dir)?; - capture.retain(config.len(), "effective recovery configuration")?; - let config_files = snapshot_recovery_paths( - [ - (PathBuf::from("config"), git_dir.join("config")), - ( - PathBuf::from("config.worktree"), - git_dir.join("config.worktree"), - ), - ], - capture, - )?; - ensure_recovery_path_has_no_symlink_ancestors( - git_dir, - Path::new("info/attributes"), - "repository attributes", - false, - )?; - let mut attributes = snapshot_recovery_paths( - [( - PathBuf::from("info/attributes"), - git_dir.join("info/attributes"), - )], - capture, - )?; - attributes.extend(snapshot_recovery_worktree_attributes(root, capture)?); - attributes.sort_by(|left, right| left.path.cmp(&right.path)); - let hooks_path = path_from_bytes(strip_byte_line_ending(&capture.required( - self, - &["rev-parse", "--path-format=absolute", "--git-path", "hooks"], - )?)); - let hooks_location = match hooks_path.strip_prefix(root) { - Ok(relative) => { - ensure_recovery_path_has_no_symlink_ancestors( - root, - relative, - "repository hook directory", - true, - )?; - RecoveryHookLocation::Repository(relative.to_owned()) - } - Err(_) => RecoveryHookLocation::External(hooks_path.clone()), - }; - let hooks = snapshot_recovery_paths([(PathBuf::from("hooks"), hooks_path)], capture)?; - Ok(RecoveryExecutionState { - config, - config_files, - attributes, - hooks_location, - hooks, - }) - } - - fn snapshot_recovery_worktree( - &self, - root: &Path, - mut paths: BTreeSet, - capture: &mut RecoveryCapture, - ) -> Result, GitError> { - let mut entries = Vec::with_capacity(paths.len()); - let mut path_bytes = paths - .iter() - .map(|path| path.as_os_str().as_encoded_bytes().len()) - .sum::(); - let mut path_count = paths.len(); - while let Some(relative) = paths.pop_first() { - let path = root.join(&relative); - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - entries.push(RecoveryWorktreeEntry { - path: relative.to_owned(), - value: RecoveryWorktreeValue::Missing, - }); - continue; - } - Err(source) => return Err(recovery_metadata_io_error(&relative, source)), - }; - let file_type = metadata.file_type(); - let value = if file_type.is_file() { - let (file, opened_metadata) = - open_recovery_regular_file(&path, &relative, &metadata)?; - let size = opened_metadata.len(); - capture.reserve_file_bytes(size)?; - let file = BufReader::new(file); - let mut file = file.take(size.saturating_add(1)); - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - let mut bytes_read = 0_u64; - loop { - let read = file - .read(&mut buffer) - .map_err(|source| recovery_metadata_io_error(&relative, source))?; - if read == 0 { - break; - } - bytes_read = bytes_read.saturating_add(read as u64); - if bytes_read > size { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because relevant file {} changed while it was fingerprinted", - relative.display() - ), - }); - } - hasher.update(&buffer[..read]); - } - ensure_recovery_regular_file_path_unchanged(&path, &relative, &opened_metadata)?; - RecoveryWorktreeValue::File { - size, - mode: recovery_file_mode(&opened_metadata), - identity: recovery_file_identity(&opened_metadata), - digest: hasher.finalize().into(), - } - } else if file_type.is_symlink() { - RecoveryWorktreeValue::Symlink( - fs::read_link(&path) - .map_err(|source| recovery_metadata_io_error(&relative, source))?, - ) - } else if file_type.is_dir() { - let children = fs::read_dir(&path) - .map_err(|source| recovery_metadata_io_error(&relative, source))?; - for child in children { - let child = - child.map_err(|source| recovery_metadata_io_error(&relative, source))?; - let child = relative.join(child.file_name()); - if paths.insert(child.clone()) { - if path_count == MAX_RECOVERY_RELEVANT_FILES { - return Err(recovery_bound_error(format!( - "relevant file count exceeds {MAX_RECOVERY_RELEVANT_FILES}" - ))); - } - path_count += 1; - let child_bytes = child.as_os_str().as_encoded_bytes().len(); - path_bytes = path_bytes.saturating_add(child_bytes); - if path_bytes > MAX_RECOVERY_PATH_BYTES { - return Err(recovery_bound_error(format!( - "relevant path bytes exceed {MAX_RECOVERY_PATH_BYTES}" - ))); - } - capture.retain(child_bytes.saturating_add(96), "relevant paths")?; - } - } - RecoveryWorktreeValue::Directory { - mode: recovery_file_mode(&metadata), - identity: recovery_file_identity(&metadata), - } - } else { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because relevant path {} has an unsupported filesystem type", - relative.display() - ), - }); - }; - entries.push(RecoveryWorktreeEntry { - path: relative, - value, - }); - } - capture.retain(entries.len() * 80, "worktree fingerprints")?; - Ok(entries) - } - - fn recovery_metadata( - &self, - git_dir: &Path, - capture: &mut RecoveryCapture, - ) -> Result, GitError> { - snapshot_recovery_metadata(git_dir, capture) - } - - fn recovery_refs( - &self, - operation: Option, - metadata: &[RecoveryMetadataEntry], - capture: &mut RecoveryCapture, - ) -> Result, GitError> { - if operation != Some(RepositoryOperation::Rebase) { - return Ok(Vec::new()); - } - - let mut references = BTreeSet::new(); - for relative in ["rebase-merge/head-name", "rebase-apply/head-name"] { - if let Some(reference) = recovery_metadata_file(metadata, relative) { - let reference = strip_byte_line_ending(reference); - if reference.starts_with(b"refs/") { - references.insert(path_from_bytes(reference).into_os_string()); - } - } - } - for relative in ["rebase-merge/update-refs", "rebase-apply/update-refs"] { - if let Some(contents) = recovery_metadata_file(metadata, relative) { - references.extend( - contents - .split(|byte| *byte == b'\n') - .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) - .filter(|line| line.starts_with(b"refs/")) - .map(|line| path_from_bytes(line).into_os_string()), - ); - } - } - - let mut args = vec![ - OsString::from("for-each-ref"), - OsString::from("--format=%(refname)%00%(objectname)%00%(symref)"), - OsString::from("refs/rewritten"), - ]; - args.extend(references); - - capture.required_os(self, args) - } - pub fn stage_path(&self, path: &Path) -> Result { self.run_path_args(["add"], Some(path), true) } @@ -1455,7 +1041,6 @@ impl Git { self.run_args_with_editor(args, false) } - #[cfg(test)] fn run_recovery_args(&self, args: Vec) -> Result { self.run_args_with_editor(args, true) } @@ -1668,7723 +1253,1285 @@ impl Git { } } -struct RecoveryTransaction { - root: PathBuf, - candidate: PathBuf, - backup_pointer: PathBuf, - baseline: RecoveryGeneration, - root_identity: (u64, u64), - candidate_identity: (u64, u64), - keep_candidate: bool, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Repository { + pub root: PathBuf, + pub branch: BranchState, + pub remotes: Vec, + pub status: WorktreeStatus, } -impl RecoveryTransaction { - fn prepare(git: &Git) -> Result { - let root = Self::supported_root(git)?; - let parent = root.parent().ok_or_else(|| { - recovery_transaction_blocked("repository root has no parent for isolated recovery") - })?; - run_recovery_prepare_hook(&root); - remove_previous_recovery_backup(&root)?; - ensure_no_retained_recovery_generation(&root)?; - let baseline = RecoveryGeneration::capture(&root)?; - let root_identity = - recovery_file_identity(&fs::symlink_metadata(&root).map_err(|source| { - recovery_transaction_io("identify repository generation", source) - })?); - let (candidate, backup_identity) = create_recovery_candidate(parent, &root)?; - let candidate_identity = - recovery_file_identity(&fs::symlink_metadata(&candidate).map_err(|source| { - recovery_transaction_io("identify isolated repository", source) - })?); - let backup_pointer = recovery_backup_pointer_path(&candidate)?; - let mut pointer_contents = candidate.as_os_str().as_encoded_bytes().to_vec(); - pointer_contents.push(0); - pointer_contents.extend_from_slice(&backup_identity); - pointer_contents.extend_from_slice(&baseline.digest()); - let mut transaction = Self { - root, - candidate, - backup_pointer, - baseline, - root_identity, - candidate_identity, - keep_candidate: false, - }; - transaction.copy_repository()?; - - let live_after_copy = RecoveryGeneration::capture(&transaction.root)?; - let copied = RecoveryGeneration::capture_isolated(&transaction.candidate)?; - if live_after_copy != transaction.baseline || copied != transaction.baseline { - return Err(recovery_transaction_blocked( - "repository changed while isolated recovery state was prepared", - )); - } - run_recovery_sidecar_hook(&recovery_backup_pointer_path(&transaction.root)?); - write_new_recovery_sidecar( - &transaction.backup_pointer, - &pointer_contents, - "record the retained recovery backup pointer", - )?; - Ok(transaction) - } - - fn supported_root(git: &Git) -> Result { - let mut capture = RecoveryCapture::default(); - let root = path_from_bytes(strip_byte_line_ending( - &capture.required(git, &["rev-parse", "--show-toplevel"])?, - )) - .canonicalize() - .map_err(|source| recovery_transaction_io("resolve repository root", source))?; - let git_dir = path_from_bytes(strip_byte_line_ending( - &capture.required(git, &["rev-parse", "--absolute-git-dir"])?, - )) - .canonicalize() - .map_err(|source| recovery_transaction_io("resolve Git directory", source))?; - let embedded_git_dir = root.join(".git"); - if !embedded_git_dir.is_dir() - || embedded_git_dir.canonicalize().map_err(|source| { - recovery_transaction_io("resolve embedded Git directory", source) - })? != git_dir - { - return Err(recovery_transaction_blocked( - "atomic recovery requires a standalone repository with an embedded .git directory", - )); - } - let common_dir = path_from_bytes(strip_byte_line_ending(&capture.required( - git, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - )?)) - .canonicalize() - .map_err(|source| recovery_transaction_io("resolve common Git directory", source))?; - if common_dir != git_dir { - return Err(recovery_transaction_blocked( - "atomic recovery does not support a shared common Git directory", - )); - } - ensure_recovery_git_storage_isolated(&root)?; - ensure_recovery_mount_tree_isolated(&root)?; - let parent = root.parent().ok_or_else(|| { - recovery_transaction_blocked("repository root has no parent for isolated recovery") - })?; - run_recovery_capability_hook(&root)?; - ensure_recovery_platform_capabilities(parent)?; - Ok(root) - } - - fn copy_repository(&mut self) -> Result<(), GitError> { - ensure_recovery_mount_tree_isolated(&self.root)?; - run_recovery_copy_hook(&self.root); - let mut command = Command::new(recovery_copy_program(&self.root)); - command - .args([ - "-a", - "--one-file-system", - "--no-preserve=links", - "--reflink=auto", - "--", - ]) - .arg(self.root.join(".")) - .arg(&self.candidate); - let limits = recovery_copy_limits(&self.root); - let candidate = self.candidate.clone(); - let mut guard = move || ensure_recovery_copy_bounds(&candidate, limits); - let output = run_bounded_recovery_process( - &mut command, - vec!["cp".to_owned(), "repository".to_owned()], - recovery_copy_duration(&self.root), - "repository copy", - Some(&mut guard), - )?; - if !output.status.success() { - return Err(recovery_transaction_blocked(format!( - "isolated repository copy failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ))); - } - fs::set_permissions( - &self.candidate, - fs::metadata(&self.root) - .map_err(|source| { - recovery_transaction_io("read repository root permissions", source) - })? - .permissions(), - ) - .map_err(|source| { - recovery_transaction_io("preserve repository root permissions", source) - })?; - Ok(()) - } - - fn run_recovery( - &self, - git: &Git, - args: Vec, - expected_state: &RecoveryState, - ) -> Result { - let candidate_git = Git::new(&self.candidate); - let mut capture = RecoveryCapture::default(); - let candidate_root = path_from_bytes(strip_byte_line_ending( - &capture.required(&candidate_git, &["rev-parse", "--show-toplevel"])?, - )); - let candidate_git_dir = path_from_bytes(strip_byte_line_ending( - &capture.required(&candidate_git, &["rev-parse", "--absolute-git-dir"])?, - )); - let candidate_execution = candidate_git.recovery_execution_state( - &candidate_root, - &candidate_git_dir, - &mut capture, - )?; - let live_state = git.recovery_state()?; - if live_state != *expected_state || candidate_execution != expected_state.execution { - return Err(recovery_transaction_blocked( - "repository state, configuration, or hooks changed while isolated recovery was prepared", - )); - } - run_recovery_execution_hook(&self.root); - let mut final_capture = RecoveryCapture::default(); - let candidate_execution = candidate_git.recovery_execution_state( - &candidate_root, - &candidate_git_dir, - &mut final_capture, - )?; - let live_state = git.recovery_state()?; - if live_state != *expected_state || candidate_execution != expected_state.execution { - return Err(recovery_transaction_blocked( - "repository state, configuration, attributes, or hooks changed before isolated recovery execution", - )); - } - let mut command = Command::new("unshare"); - command - .args([ - "--user", - "--map-root-user", - "--mount", - "--pid", - "--kill-child=SIGKILL", - "--fork", - "sh", - "-c", - "mount --bind \"$1\" \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; cd \"$2\" || { printf '%s\\n' 'bitbygit: recovery namespace setup failed' >&2; exit 125; }; shift 2; exec git -c core.attributesFile=/dev/null \"$@\"", - "bitbygit-recovery", - ]) - .arg(&self.candidate) - .arg(&self.root) - .args(&args) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_ASKPASS", "") - .env("SSH_ASKPASS", "") - .env("SSH_ASKPASS_REQUIRE", "never") - .env("GIT_EDITOR", "true") - .env("GIT_SEQUENCE_EDITOR", "true"); - isolate_recovery_git_environment(&mut command); - for variable in RECOVERY_GIT_ENVIRONMENT { - command.env_remove(variable); - } - if git.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { - let ssh_executable = git - .ssh_executable - .as_ref() - .map(|path| shell_quote(&path.to_string_lossy())) - .unwrap_or_else(|| "ssh".to_owned()); - command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); - } - let output = run_bounded_recovery_command( - &mut command, - args.clone(), - recovery_command_duration(&self.root), - )?; - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if !output.status.success() { - if String::from_utf8_lossy(&output.stderr).lines().any(|line| { - line.starts_with("unshare: ") || line == "bitbygit: recovery namespace setup failed" - }) { - return Err(recovery_capability_unavailable(format!( - "the recovery namespace could not be established: {}", - String::from_utf8_lossy(&output.stderr).trim() - ))); - } - return Err(GitError::GitFailed { - args, - status: output.status, - stdout, - stderr, - }); - } - Ok(GitOutput { - status: output.status, - stdout, - stderr, - }) - } - - fn has_advanced_rebase_conflict(&self, expected_state: &RecoveryState) -> bool { - let candidate_git = Git::new(&self.candidate); - let Ok(candidate_state) = candidate_git.recovery_state() else { - return false; - }; - let mut capture = RecoveryCapture::default(); - let Ok(status) = capture.required( - &candidate_git, - &["status", "--porcelain=v2", "--branch", "-z"], - ) else { - return false; - }; - let Ok(mut candidate_status) = parse_status_bytes(&status) else { - return false; - }; - candidate_status.operation = candidate_state.operation; - candidate_state.operation == Some(RepositoryOperation::Rebase) - && candidate_status.operation == Some(RepositoryOperation::Rebase) - && !candidate_status.conflicted_files().is_empty() - && candidate_state.rebase_progressed_from(expected_state) - } - - fn promote( - &mut self, - live_git: &Git, - expected_state: &RecoveryState, - ) -> Result { - if live_git.recovery_state()? != *expected_state - || RecoveryGeneration::capture(&self.root)? != self.baseline - || recovery_path_identity(&self.root)? != self.root_identity - { - return Err(recovery_transaction_blocked( - "repository changed while recovery executed in isolation", - )); - } - - run_recovery_promotion_hook(&self.root); - ensure_recovery_git_storage_isolated(&self.candidate)?; - let candidate_generation = RecoveryGeneration::capture_isolated(&self.candidate)?; - if recovery_path_identity(&self.root)? != self.root_identity - || recovery_path_identity(&self.candidate)? != self.candidate_identity - { - return Err(recovery_transaction_blocked( - "repository or isolated recovery candidate changed before atomic promotion", - )); - } - ensure_recovery_mount_tree_isolated(&self.root)?; - atomic_exchange_directories(&self.root, &self.candidate)?; - self.keep_candidate = true; - run_recovery_post_exchange_hook(&self.root); - let old_generation = RecoveryGeneration::capture(&self.candidate); - let installed_generation = RecoveryGeneration::capture_isolated(&self.root); - run_recovery_installed_capture_hook(&self.root); - let identities_match = recovery_path_identity(&self.root) - .is_ok_and(|identity| identity == self.candidate_identity) - && recovery_path_identity(&self.candidate) - .is_ok_and(|identity| identity == self.root_identity); - if !identities_match - || !matches!(&old_generation, Ok(generation) if generation == &self.baseline) - || !matches!(&installed_generation, Ok(generation) if generation == &candidate_generation) - { - self.keep_candidate = true; - return Err(recovery_transaction_blocked( - match (old_generation, installed_generation) { - (Err(error), _) | (_, Err(error)) => format!( - "repository metadata changed during atomic recovery promotion; no rollback was attempted through uncertain pathnames and the other exposed generation was retained at {}: {error}", - self.candidate.display() - ), - _ => format!( - "repository changed during atomic recovery promotion; no rollback was attempted through uncertain pathnames and the other exposed generation was retained at {}", - self.candidate.display() - ), - }, - )); - } - Ok(self.candidate.clone()) - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitOutput { + pub status: ExitStatus, + pub stdout: String, + pub stderr: String, } -impl Drop for RecoveryTransaction { - fn drop(&mut self) { - if !self.keep_candidate - && remove_recovery_directory(&self.candidate, self.candidate_identity).is_ok() - { - let _result = fs::remove_file(recovery_candidate_owner_path(&self.candidate)); - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeadTarget { + pub oid: Option, + pub reference: Option, } -#[derive(Debug, PartialEq, Eq)] -struct RecoveryGeneration { - root: RecoveryFilesystemMetadata, - entries: Vec, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + pub name: String, + pub reference: String, + pub oid: String, + pub upstream: Option, + pub current: bool, + pub kind: BranchKind, } -impl RecoveryGeneration { - fn capture(root: &Path) -> Result { - Self::capture_with_hard_linked_git_objects(root, true) - } - - fn capture_isolated(root: &Path) -> Result { - Self::capture_with_hard_linked_git_objects(root, false) - } - - fn capture_with_hard_linked_git_objects( - root: &Path, - allow_hard_linked_git_objects: bool, - ) -> Result { - let root_metadata = fs::symlink_metadata(root) - .map_err(|source| recovery_transaction_io("inspect repository root", source))?; - if !root_metadata.file_type().is_dir() { - return Err(recovery_transaction_blocked( - "repository root changed while its generation was captured", - )); - } - let root_mount_id = recovery_mount_id(root, Path::new("."))?; - let root_value = recovery_filesystem_metadata( - root, - Path::new("."), - &root_metadata, - None, - root_mount_id, - allow_hard_linked_git_objects, - )?; - let mut entries = Vec::new(); - let mut pending = vec![PathBuf::new()]; - let mut path_bytes = 0_usize; - let mut file_bytes = 0_u64; - while let Some(relative_dir) = pending.pop() { - let directory = root.join(&relative_dir); - let children = read_recovery_generation_children( - &directory, - &relative_dir, - entries.len(), - &mut path_bytes, - MAX_RECOVERY_GENERATION_ENTRIES, - )?; - for child in children.into_iter().rev() { - let relative = relative_dir.join(child.file_name()); - let path = child.path(); - let metadata = fs::symlink_metadata(&path).map_err(|source| { - recovery_transaction_io("inspect repository generation entry", source) - })?; - let file_type = metadata.file_type(); - let value = if file_type.is_dir() { - pending.push(relative.clone()); - RecoveryGenerationValue::Directory { - metadata: recovery_filesystem_metadata( - &path, - &relative, - &metadata, - None, - root_mount_id, - allow_hard_linked_git_objects, - )?, - } - } else if file_type.is_file() { - let (mut file, opened_metadata) = - open_recovery_regular_file(&path, &relative, &metadata)?; - file_bytes = file_bytes.saturating_add(opened_metadata.len()); - if file_bytes > MAX_RECOVERY_GENERATION_FILE_BYTES { - return Err(recovery_transaction_blocked( - "repository generation exceeds atomic recovery content bound", - )); - } - let file_metadata = recovery_filesystem_metadata( - &path, - &relative, - &opened_metadata, - Some(&file), - root_mount_id, - allow_hard_linked_git_objects, - )?; - run_recovery_generation_file_read_hook(&path); - let mut hasher = Sha256::new(); - { - let mut reader = (&mut file).take(opened_metadata.len().saturating_add(1)); - let mut buffer = [0_u8; 64 * 1024]; - let mut bytes_read = 0_u64; - loop { - let read = reader.read(&mut buffer).map_err(|source| { - recovery_transaction_io("hash repository generation file", source) - })?; - if read == 0 { - break; - } - bytes_read = bytes_read.saturating_add(read as u64); - if bytes_read > opened_metadata.len() { - return Err(recovery_transaction_blocked(format!( - "repository generation file {} grew while it was captured", - relative.display() - ))); - } - hasher.update(&buffer[..read]); - } - } - let current = fs::symlink_metadata(&path).map_err(|source| { - recovery_transaction_io("recheck repository generation file", source) - })?; - if !current.is_file() || current.len() != opened_metadata.len() { - return Err(recovery_transaction_blocked( - "repository generation changed while it was captured", - )); - } - let current_metadata = recovery_filesystem_metadata( - &path, - &relative, - ¤t, - Some(&file), - root_mount_id, - allow_hard_linked_git_objects, - )?; - if current_metadata != file_metadata { - return Err(recovery_transaction_blocked( - "repository generation changed while it was captured", - )); - } - RecoveryGenerationValue::File { - metadata: file_metadata, - size: opened_metadata.len(), - digest: hasher.finalize().into(), - } - } else if file_type.is_symlink() { - if relative.starts_with(".git") { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support symlinked Git storage {}", - relative.display() - ))); - } - RecoveryGenerationValue::Symlink { - metadata: recovery_filesystem_metadata( - &path, - &relative, - &metadata, - None, - root_mount_id, - allow_hard_linked_git_objects, - )?, - target: fs::read_link(&path).map_err(|source| { - recovery_transaction_io("read repository generation symlink", source) - })?, - } - } else { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support special filesystem entry {}", - relative.display() - ))); - }; - entries.push(RecoveryGenerationEntry { - path: relative, - value, - }); - } - } - entries.sort_by(|left, right| left.path.cmp(&right.path)); - let current_root = fs::symlink_metadata(root) - .map_err(|source| recovery_transaction_io("recheck repository root", source))?; - let current_root_value = recovery_filesystem_metadata( - root, - Path::new("."), - ¤t_root, - None, - root_mount_id, - allow_hard_linked_git_objects, - )?; - if current_root_value != root_value { - return Err(recovery_transaction_blocked( - "repository root changed while its generation was captured", - )); - } - Ok(Self { - root: root_value, - entries, - }) - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchTarget { + pub name: String, + pub reference: String, + pub oid: String, + pub kind: BranchKind, +} - fn digest(&self) -> [u8; RECOVERY_GENERATION_DIGEST_BYTES] { - let mut digest = Sha256::new(); - update_recovery_filesystem_metadata_digest(&mut digest, &self.root); - digest.update((self.entries.len() as u64).to_le_bytes()); - for entry in &self.entries { - update_recovery_digest_bytes(&mut digest, entry.path.as_os_str().as_encoded_bytes()); - match &entry.value { - RecoveryGenerationValue::Directory { metadata } => { - digest.update([0]); - update_recovery_filesystem_metadata_digest(&mut digest, metadata); - } - RecoveryGenerationValue::File { - metadata, - size, - digest: file_digest, - } => { - digest.update([1]); - update_recovery_filesystem_metadata_digest(&mut digest, metadata); - digest.update(size.to_le_bytes()); - digest.update(file_digest); - } - RecoveryGenerationValue::Symlink { metadata, target } => { - digest.update([2]); - update_recovery_filesystem_metadata_digest(&mut digest, metadata); - update_recovery_digest_bytes( - &mut digest, - target.as_os_str().as_encoded_bytes(), - ); - } - } - } - digest.finalize().into() - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BranchKind { + Local, + Remote, } -fn read_recovery_generation_children( - directory: &Path, - relative_dir: &Path, - captured_entries: usize, - path_bytes: &mut usize, - entry_limit: usize, -) -> Result, GitError> { - let mut children = Vec::new(); - for child in fs::read_dir(directory) - .map_err(|source| recovery_transaction_io("read repository generation", source))? - { - let child = child - .map_err(|source| recovery_transaction_io("read repository generation", source))?; - let relative = relative_dir.join(child.file_name()); - let next_path_bytes = - path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); - if captured_entries.saturating_add(children.len()) >= entry_limit - || next_path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_transaction_blocked( - "repository generation exceeds atomic recovery entry or path bounds", - )); - } - *path_bytes = next_path_bytes; - children.push(child); - } - children.sort_by_key(|entry| entry.file_name()); - Ok(children) +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawGitOutput { + stdout: Vec, } -#[derive(Debug, PartialEq, Eq)] -struct RecoveryGenerationEntry { - path: PathBuf, - value: RecoveryGenerationValue, +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawProcessOutput { + args: Vec, + status: ExitStatus, + stdout: Vec, + stderr: Vec, } -#[derive(Debug, PartialEq, Eq)] -enum RecoveryGenerationValue { - Directory { - metadata: RecoveryFilesystemMetadata, +#[derive(Debug)] +pub enum GitError { + Io { + args: Vec, + source: std::io::Error, + }, + Utf8 { + args: Vec, + stream: OutputStream, + source: FromUtf8Error, + }, + GitFailed { + args: Vec, + status: ExitStatus, + stdout: String, + stderr: String, }, - File { - metadata: RecoveryFilesystemMetadata, - size: u64, - digest: [u8; 32], + Blocked { + message: String, }, - Symlink { - metadata: RecoveryFilesystemMetadata, - target: PathBuf, + Parse { + message: String, }, } -#[derive(Debug, PartialEq, Eq)] -struct RecoveryFilesystemMetadata { - mode: u32, - owner: (u32, u32), - modified: (i64, i64), - inode_flags: u32, -} - -fn update_recovery_filesystem_metadata_digest( - digest: &mut Sha256, - metadata: &RecoveryFilesystemMetadata, -) { - digest.update(metadata.mode.to_le_bytes()); - digest.update(metadata.owner.0.to_le_bytes()); - digest.update(metadata.owner.1.to_le_bytes()); - digest.update(metadata.modified.0.to_le_bytes()); - digest.update(metadata.modified.1.to_le_bytes()); - digest.update(metadata.inode_flags.to_le_bytes()); -} - -fn update_recovery_digest_bytes(digest: &mut Sha256, bytes: &[u8]) { - digest.update((bytes.len() as u64).to_le_bytes()); - digest.update(bytes); -} - -fn recovery_filesystem_metadata( - path: &Path, - relative: &Path, - metadata: &fs::Metadata, - opened: Option<&fs::File>, - root_mount_id: u64, - allow_hard_linked_git_objects: bool, -) -> Result { - if recovery_mount_id(path, relative)? != root_mount_id { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support mounted filesystem entry {}", - relative.display() - ))); - } - ensure_no_recovery_extended_attributes(path, relative)?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - - if metadata.file_type().is_file() - && metadata.nlink() != 1 - && !(allow_hard_linked_git_objects && relative.starts_with(".git/objects")) - { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support hard-linked file {}", - relative.display() - ))); - } - Ok(RecoveryFilesystemMetadata { - mode: recovery_file_mode(metadata), - owner: (metadata.uid(), metadata.gid()), - modified: (metadata.mtime(), metadata.mtime_nsec()), - inode_flags: recovery_inode_flags(path, relative, metadata, opened)?, - }) - } - #[cfg(not(unix))] - { - let _ = (path, relative, opened, allow_hard_linked_git_objects); - Ok(RecoveryFilesystemMetadata { - mode: recovery_file_mode(metadata), - owner: (0, 0), - modified: (0, 0), - inode_flags: 0, - }) - } -} - -#[cfg(target_os = "linux")] -fn recovery_mount_id(path: &Path, relative: &Path) -> Result { - use rustix::fs::{AtFlags, CWD, StatxFlags, statx}; - - let result = statx( - CWD, - path, - AtFlags::NO_AUTOMOUNT | AtFlags::SYMLINK_NOFOLLOW, - StatxFlags::MNT_ID, - ) - .map_err(|source| { - recovery_capability_unavailable(format!( - "the mount identity of {} could not be inspected: {source}", - relative.display() - )) - })?; - if result.stx_mask & StatxFlags::MNT_ID.bits() == 0 { - return Err(recovery_capability_unavailable( - "Linux mount identity inspection is not available", - )); - } - Ok(result.stx_mnt_id) -} - -#[cfg(not(target_os = "linux"))] -fn recovery_mount_id(_path: &Path, _relative: &Path) -> Result { - Ok(0) -} - -fn ensure_recovery_mount_tree_isolated(root: &Path) -> Result<(), GitError> { - let root_metadata = fs::symlink_metadata(root) - .map_err(|source| recovery_transaction_io("inspect recovery cleanup root", source))?; - if !root_metadata.file_type().is_dir() { - return Err(recovery_transaction_blocked( - "recovery directory changed before its mount boundary was checked", - )); - } - let root_mount_id = recovery_mount_id(root, Path::new("."))?; - let mut pending = vec![PathBuf::new()]; - let mut entries = 0_usize; - let mut path_bytes = 0_usize; - while let Some(relative_dir) = pending.pop() { - let directory = root.join(&relative_dir); - let children = fs::read_dir(&directory) - .map_err(|source| recovery_transaction_io("inspect recovery mount tree", source))?; - for child in children { - let child = child - .map_err(|source| recovery_transaction_io("inspect recovery mount tree", source))?; - let relative = relative_dir.join(child.file_name()); - entries = entries.saturating_add(1); - path_bytes = path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); - if entries > MAX_RECOVERY_GENERATION_ENTRIES - || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_transaction_blocked( - "repository generation exceeds atomic recovery entry or path bounds", - )); - } - let path = child.path(); - let metadata = fs::symlink_metadata(&path).map_err(|source| { - recovery_transaction_io("inspect recovery mount tree entry", source) - })?; - if recovery_mount_id(&path, &relative)? != root_mount_id { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support mounted filesystem entry {}", - relative.display() - ))); +impl Display for GitError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Io { args, source } => { + write!(formatter, "failed to run git {}: {source}", args.join(" ")) } - if metadata.file_type().is_dir() { - pending.push(relative); + Self::Utf8 { + args, + stream, + source, + } => write!( + formatter, + "git {} returned non-UTF-8 {stream}: {source}", + args.join(" ") + ), + Self::GitFailed { + args, + status, + stdout, + stderr, + .. + } => { + let detail = if !stderr.trim().is_empty() { + stderr.trim() + } else if !stdout.trim().is_empty() { + stdout.trim() + } else { + "no output" + }; + write!( + formatter, + "git {} failed with status {status}: {detail}", + args.join(" ") + ) } + Self::Blocked { message } => formatter.write_str(message), + Self::Parse { message } => write!(formatter, "failed to parse git output: {message}"), } } - if recovery_mount_id(root, Path::new("."))? != root_mount_id { - return Err(recovery_transaction_blocked( - "recovery directory mount changed while it was checked", - )); - } - Ok(()) } -#[cfg(target_os = "linux")] -fn remove_recovery_directory(path: &Path, expected_identity: (u64, u64)) -> Result<(), GitError> { - use rustix::fs::{CWD, Mode, OFlags, openat}; - - let parent = path.parent().ok_or_else(|| { - recovery_transaction_blocked("recovery cleanup path has no parent directory") - })?; - let name = path.file_name().ok_or_else(|| { - recovery_transaction_blocked("recovery cleanup path has no directory name") - })?; - let name = CString::new(name.as_encoded_bytes()).map_err(|_| { - recovery_transaction_blocked("recovery cleanup path contains an invalid directory name") - })?; - let parent_fd = openat( - CWD, - parent, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(|source| recovery_cleanup_error("open recovery cleanup parent", source))?; - let root_fd = match openat( - &parent_fd, - name.as_c_str(), - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, - Mode::empty(), - ) { - Ok(root_fd) => root_fd, - Err(rustix::io::Errno::NOENT) => return Ok(()), - Err(source) => { - return Err(recovery_cleanup_error( - "open recovery cleanup directory", - source, - )); +impl Error for GitError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Utf8 { source, .. } => Some(source), + Self::GitFailed { .. } | Self::Blocked { .. } | Self::Parse { .. } => None, } - }; - if recovery_fd_identity(&root_fd)? != expected_identity { - return Err(recovery_transaction_blocked( - "recovery cleanup directory identity changed; refusing to remove it", - )); - } - let root_mount_id = recovery_fd_mount_id(&root_fd)?; - run_recovery_cleanup_hook(path); - let mut entries = 0_usize; - let mut path_bytes = 0_usize; - remove_recovery_directory_contents(&root_fd, root_mount_id, &mut entries, &mut path_bytes)?; - ensure_recovery_cleanup_entry( - &parent_fd, - name.as_c_str(), - expected_identity, - root_mount_id, - )?; - rustix::fs::unlinkat(&parent_fd, name.as_c_str(), rustix::fs::AtFlags::REMOVEDIR) - .map_err(|source| recovery_cleanup_error("remove recovery cleanup directory", source))?; - Ok(()) + } } -#[cfg(target_os = "linux")] -fn remove_recovery_file(path: &Path, expected_identity: (u64, u64)) -> Result<(), GitError> { - use rustix::fs::{CWD, FileType, Mode, OFlags, openat}; - - let parent = path.parent().ok_or_else(|| { - recovery_transaction_blocked("recovery cleanup file has no parent directory") - })?; - let name = path - .file_name() - .ok_or_else(|| recovery_transaction_blocked("recovery cleanup file has no file name"))?; - let name = CString::new(name.as_encoded_bytes()) - .map_err(|_| recovery_transaction_blocked("recovery cleanup file has an invalid name"))?; - let parent_fd = openat( - CWD, - parent, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(|source| recovery_cleanup_error("open recovery file cleanup parent", source))?; - let entry = match openat( - &parent_fd, - name.as_c_str(), - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - ) { - Ok(entry) => entry, - Err(rustix::io::Errno::NOENT) => return Ok(()), - Err(source) => { - return Err(recovery_cleanup_error("open recovery cleanup file", source)); - } - }; - let metadata = rustix::fs::fstat(&entry) - .map_err(|source| recovery_cleanup_error("inspect recovery cleanup file", source))?; - if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile - || recovery_fd_identity(&entry)? != expected_identity - { - return Err(recovery_transaction_blocked( - "recovery cleanup file identity changed; refusing to remove it", - )); - } - let mount_id = recovery_fd_mount_id(&entry)?; - ensure_recovery_cleanup_entry(&parent_fd, name.as_c_str(), expected_identity, mount_id)?; - rustix::fs::unlinkat(&parent_fd, name.as_c_str(), rustix::fs::AtFlags::empty()) - .map_err(|source| recovery_cleanup_error("remove recovery cleanup file", source))?; - Ok(()) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, } -#[cfg(target_os = "linux")] -fn remove_recovery_directory_contents( - directory: &impl std::os::fd::AsFd, - root_mount_id: u64, - entries: &mut usize, - path_bytes: &mut usize, -) -> Result<(), GitError> { - use rustix::fs::{FileType, Mode, OFlags, openat}; - - let names = read_recovery_cleanup_names( - directory, - entries, - path_bytes, - MAX_RECOVERY_GENERATION_ENTRIES, - )?; - for name in names { - let entry_fd = match openat( - directory, - name.as_c_str(), - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - ) { - Ok(entry_fd) => entry_fd, - Err(rustix::io::Errno::NOENT) => continue, - Err(source) => { - return Err(recovery_cleanup_error( - "open recovery cleanup entry", - source, - )); - } - }; - let identity = recovery_fd_identity(&entry_fd)?; - let mount_id = recovery_fd_mount_id(&entry_fd)?; - if mount_id != root_mount_id { - return Err(recovery_transaction_blocked(format!( - "atomic recovery cleanup refuses mounted filesystem entry {}", - name.to_string_lossy() - ))); - } - let file_type = FileType::from_raw_mode( - rustix::fs::fstat(&entry_fd) - .map_err(|source| recovery_cleanup_error("inspect recovery cleanup entry", source))? - .st_mode, - ); - if file_type == FileType::Directory { - let child = openat( - directory, - name.as_c_str(), - OFlags::RDONLY - | OFlags::DIRECTORY - | OFlags::NOFOLLOW - | OFlags::NONBLOCK - | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(|source| recovery_cleanup_error("open recovery cleanup child", source))?; - if recovery_fd_identity(&child)? != identity - || recovery_fd_mount_id(&child)? != root_mount_id - { - return Err(recovery_transaction_blocked( - "recovery cleanup entry changed while it was opened", - )); - } - remove_recovery_directory_contents(&child, root_mount_id, entries, path_bytes)?; - ensure_recovery_cleanup_entry(directory, name.as_c_str(), identity, root_mount_id)?; - rustix::fs::unlinkat(directory, name.as_c_str(), rustix::fs::AtFlags::REMOVEDIR) - .map_err(|source| { - recovery_cleanup_error("remove recovery cleanup child", source) - })?; - } else { - ensure_recovery_cleanup_entry(directory, name.as_c_str(), identity, root_mount_id)?; - rustix::fs::unlinkat(directory, name.as_c_str(), rustix::fs::AtFlags::empty()) - .map_err(|source| { - recovery_cleanup_error("remove recovery cleanup entry", source) - })?; +impl Display for OutputStream { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Stdout => formatter.write_str("stdout"), + Self::Stderr => formatter.write_str("stderr"), } } - Ok(()) } -#[cfg(target_os = "linux")] -fn read_recovery_cleanup_names( - directory: &impl std::os::fd::AsFd, - entries: &mut usize, - path_bytes: &mut usize, - entry_limit: usize, -) -> Result, GitError> { - let mut names = Vec::new(); - for entry in rustix::fs::Dir::read_from(directory) - .map_err(|source| recovery_cleanup_error("read recovery cleanup directory", source))? - { - let entry = entry - .map_err(|source| recovery_cleanup_error("read recovery cleanup entry", source))?; - if matches!(entry.file_name().to_bytes(), b"." | b"..") { - continue; - } - let name = entry.file_name().to_owned(); - let next_path_bytes = path_bytes.saturating_add(name.as_bytes().len()); - if *entries >= entry_limit || next_path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES { - return Err(recovery_transaction_blocked( - "recovery cleanup exceeds atomic recovery entry or path bounds", - )); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchState { + pub head: Head, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub unborn: bool, +} + +impl Default for BranchState { + fn default() -> Self { + Self { + head: Head::Unborn, + upstream: None, + ahead: 0, + behind: 0, + unborn: false, } - *entries += 1; - *path_bytes = next_path_bytes; - names.push(name); } - Ok(names) } -#[cfg(target_os = "linux")] -fn ensure_recovery_cleanup_entry( - parent: &impl std::os::fd::AsFd, - name: &CStr, - expected_identity: (u64, u64), - root_mount_id: u64, -) -> Result<(), GitError> { - use rustix::fs::{Mode, OFlags, openat}; - - let entry = openat( - parent, - name, - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(|source| recovery_cleanup_error("recheck recovery cleanup entry", source))?; - if recovery_fd_identity(&entry)? != expected_identity { - return Err(recovery_transaction_blocked(format!( - "recovery cleanup entry {} changed before removal", - name.to_string_lossy() - ))); - } - if recovery_fd_mount_id(&entry)? != root_mount_id { - return Err(recovery_transaction_blocked(format!( - "atomic recovery cleanup refuses mounted filesystem entry {}", - name.to_string_lossy() - ))); - } - Ok(()) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Head { + Branch(String), + Detached(String), + Unborn, } -#[cfg(target_os = "linux")] -fn recovery_fd_identity(fd: &impl std::os::fd::AsFd) -> Result<(u64, u64), GitError> { - let metadata = rustix::fs::fstat(fd) - .map_err(|source| recovery_cleanup_error("identify opened recovery entry", source))?; - Ok((metadata.st_dev, metadata.st_ino)) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepositoryOperation { + Merge, + Rebase, } -#[cfg(target_os = "linux")] -fn recovery_fd_mount_id(fd: &impl std::os::fd::AsFd) -> Result { - use rustix::fs::{AtFlags, StatxFlags, statx}; - - let result = statx( - fd, - c"", - AtFlags::EMPTY_PATH | AtFlags::NO_AUTOMOUNT | AtFlags::SYMLINK_NOFOLLOW, - StatxFlags::MNT_ID, - ) - .map_err(|source| { - recovery_capability_unavailable(format!( - "an opened recovery entry mount identity could not be inspected: {source}" - )) - })?; - if result.stx_mask & StatxFlags::MNT_ID.bits() == 0 { - return Err(recovery_capability_unavailable( - "Linux mount identity inspection is not available", - )); +impl RepositoryOperation { + fn label(self) -> &'static str { + match self { + Self::Merge => "merge", + Self::Rebase => "rebase", + } } - Ok(result.stx_mnt_id) -} - -#[cfg(target_os = "linux")] -fn recovery_cleanup_error(action: &str, source: rustix::io::Errno) -> GitError { - recovery_transaction_io( - action, - std::io::Error::from_raw_os_error(source.raw_os_error()), - ) } -#[cfg(not(target_os = "linux"))] -fn remove_recovery_directory(_path: &Path, _expected_identity: (u64, u64)) -> Result<(), GitError> { - Err(recovery_transaction_blocked( - "safe recovery cleanup is not supported on this platform", - )) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryAction { + Continue, + Abort, + Skip, } -#[cfg(not(target_os = "linux"))] -fn remove_recovery_file(_path: &Path, _expected_identity: (u64, u64)) -> Result<(), GitError> { - Err(recovery_transaction_blocked( - "safe recovery file cleanup is not supported on this platform", - )) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryState { + operation: Option, + head: HeadTarget, + status: Vec, + index: Vec, + worktree: Vec, } -#[cfg(target_os = "linux")] -fn recovery_inode_flags( - path: &Path, - relative: &Path, - expected: &fs::Metadata, - opened: Option<&fs::File>, -) -> Result { - use std::os::unix::fs::OpenOptionsExt; - - let owned; - let file = if let Some(file) = opened { - file - } else if expected.file_type().is_symlink() { - return Ok(0); - } else { - owned = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(path) - .map_err(|source| { - recovery_transaction_blocked(format!( - "repository metadata {} changed while it was opened: {source}", - relative.display() - )) - })?; - let current = owned.metadata().map_err(|source| { - recovery_transaction_io("inspect opened repository metadata", source) - })?; - if recovery_file_identity(¤t) != recovery_file_identity(expected) - || current.file_type() != expected.file_type() - { - return Err(recovery_transaction_blocked(format!( - "repository metadata {} changed while it was opened", - relative.display() - ))); +impl RecoveryAction { + fn label(self) -> &'static str { + match self { + Self::Continue => "continue", + Self::Abort => "abort", + Self::Skip => "skip", } - &owned - }; - Ok(rustix::fs::ioctl_getflags(file) - .map(|flags| flags.bits() & rustix::fs::IFlags::all().bits()) - .unwrap_or(0)) + } } -#[cfg(all(unix, not(target_os = "linux")))] -fn recovery_inode_flags( - _path: &Path, - _relative: &Path, - _expected: &fs::Metadata, - _opened: Option<&fs::File>, -) -> Result { - Ok(0) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Remote { + pub name: String, + pub fetch_url: Option, + pub push_url: Option, } -#[cfg(target_os = "linux")] -fn ensure_no_recovery_extended_attributes(path: &Path, relative: &Path) -> Result<(), GitError> { - let mut attributes = xattr::list(path).map_err(|source| { - recovery_transaction_io("inspect repository extended attributes", source) - })?; - if attributes.next().is_some() { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support extended attributes or ACLs on {}", - relative.display() - ))); - } - Ok(()) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeStatus { + pub branch: BranchState, + pub entries: Vec, + pub operation: Option, } -#[cfg(not(target_os = "linux"))] -fn ensure_no_recovery_extended_attributes(_path: &Path, _relative: &Path) -> Result<(), GitError> { - Ok(()) -} +impl WorktreeStatus { + pub fn is_clean(&self) -> bool { + self.entries.is_empty() + } -fn ensure_recovery_git_storage_isolated(root: &Path) -> Result<(), GitError> { - let git_dir = root.join(".git"); - let alternates = git_dir.join("objects/info/alternates"); - match fs::symlink_metadata(&alternates) { - Ok(_) => { - return Err(recovery_transaction_blocked( - "atomic recovery does not support Git alternate object storage", - )); - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(recovery_transaction_io("inspect Git alternates", source)), - } - let mut pending = vec![git_dir]; - let mut entries = 0_usize; - let mut path_bytes = 0_usize; - while let Some(directory) = pending.pop() { - for child in fs::read_dir(&directory) - .map_err(|source| recovery_transaction_io("inspect Git storage", source))? - { - let child = - child.map_err(|source| recovery_transaction_io("inspect Git storage", source))?; - let path = child.path(); - entries = entries.saturating_add(1); - path_bytes = path_bytes.saturating_add(path.as_os_str().as_encoded_bytes().len()); - if entries > MAX_RECOVERY_GENERATION_ENTRIES - || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_transaction_blocked( - "Git storage exceeds atomic recovery entry or path bounds", - )); - } - let metadata = fs::symlink_metadata(&path) - .map_err(|source| recovery_transaction_io("inspect Git storage", source))?; - if metadata.file_type().is_symlink() { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support symlinked Git storage {}", - path.strip_prefix(root).unwrap_or(&path).display() - ))); - } - if metadata.file_type().is_dir() { - pending.push(path); - } - } + pub fn staged_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| { + matches!( + entry.entry_type, + StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied + ) && entry.index != ChangeKind::Unmodified + }) + .collect() } - Ok(()) -} -fn write_new_recovery_sidecar( - path: impl AsRef, - contents: &[u8], - action: &str, -) -> Result<(), GitError> { - let path = path.as_ref(); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); + pub fn unstaged_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| { + matches!( + entry.entry_type, + StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied + ) && entry.worktree != ChangeKind::Unmodified + }) + .collect() } - let mut file = options - .open(path) - .map_err(|source| recovery_transaction_io(action, source))?; - if let Err(source) = file.write_all(contents) { - let _ = fs::remove_file(path); - return Err(recovery_transaction_io(action, source)); + + pub fn untracked_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| entry.entry_type == StatusEntryType::Untracked) + .collect() } - Ok(()) -} -fn create_recovery_candidate(parent: &Path, root: &Path) -> Result<(PathBuf, Vec), GitError> { - for attempt in 0..100_u32 { - let candidate = parent.join(format!( - "{RECOVERY_CANDIDATE_PREFIX}{}-{}-{attempt}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let mut builder = fs::DirBuilder::new(); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); - } - match builder.create(&candidate) { - Ok(()) => { - let root_identity = - recovery_file_identity(&fs::symlink_metadata(root).map_err(|source| { - recovery_transaction_io("identify repository generation", source) - })?); - let candidate_identity = - recovery_file_identity(&fs::symlink_metadata(&candidate).map_err( - |source| recovery_transaction_io("identify isolated repository", source), - )?); - let mut identity = Sha256::new(); - identity.update(root.as_os_str().as_encoded_bytes()); - identity.update(candidate.as_os_str().as_encoded_bytes()); - for value in [ - root_identity.0, - root_identity.1, - candidate_identity.0, - candidate_identity.1, - ] { - identity.update(value.to_le_bytes()); - } - let mut backup_identity = Vec::with_capacity(64); - backup_identity.extend_from_slice(&root_identity.0.to_le_bytes()); - backup_identity.extend_from_slice(&root_identity.1.to_le_bytes()); - backup_identity.extend_from_slice(&candidate_identity.0.to_le_bytes()); - backup_identity.extend_from_slice(&candidate_identity.1.to_le_bytes()); - backup_identity.extend_from_slice(&identity.finalize()); - if let Err(error) = write_new_recovery_sidecar( - recovery_candidate_owner_path(&candidate), - &backup_identity, - "record isolated repository ownership", - ) { - let _result = fs::remove_dir(&candidate); - return Err(error); - } - return Ok((candidate, backup_identity)); - } - Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(source) => { - return Err(recovery_transaction_io( - "create isolated repository", - source, - )); - } - } + pub fn conflicted_files(&self) -> Vec<&StatusEntry> { + self.entries + .iter() + .filter(|entry| entry.entry_type == StatusEntryType::Conflict) + .collect() } - Err(recovery_transaction_blocked( - "could not reserve an isolated repository path", - )) } -fn recovery_candidate_owner_path(candidate: &Path) -> PathBuf { - let mut owner = candidate.as_os_str().to_os_string(); - owner.push(".owner"); - PathBuf::from(owner) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEntry { + pub path: PathBuf, + pub original_path: Option, + pub index: ChangeKind, + pub worktree: ChangeKind, + pub entry_type: StatusEntryType, } -fn recovery_backup_pointer_path(root: &Path) -> Result { - Ok(root.join(".git").join(RECOVERY_BACKUP_POINTER)) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusEntryType { + Ordinary, + Renamed, + /// Porcelain v2 reserves the `2` record family for renames and copies. Git + /// status does not normally emit copies, but the parser keeps the model + /// explicit for forward-compatible callers. + Copied, + Untracked, + Ignored, + Conflict, } -struct RecoveryBackup { - path: PathBuf, - owner_identity: (u64, u64), - pointer_identity: (u64, u64), -} - -fn recovery_backup_record_from_pointer( - expected_root: &Path, -) -> Result, GitError> { - let pointer = recovery_backup_pointer_path(expected_root)?; - let pointer_metadata = match fs::symlink_metadata(&pointer) { - Ok(metadata) => metadata, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => { - return Err(recovery_transaction_io( - "inspect the retained recovery backup pointer", - source, - )); - } - }; - let bytes = read_recovery_sidecar( - &pointer, - &pointer_metadata, - MAX_RECOVERY_BACKUP_POINTER_BYTES, - None, - )?; - let Some(separator) = bytes.iter().position(|byte| *byte == 0) else { - return Err(invalid_recovery_backup_pointer()); - }; - let backup = path_from_bytes(&bytes[..separator]); - let record = &bytes[separator + 1..]; - if record.len() != RECOVERY_BACKUP_IDENTITY_BYTES + RECOVERY_GENERATION_DIGEST_BYTES { - return Err(invalid_recovery_backup_pointer()); - } - let (identity, _generation_digest) = record.split_at(RECOVERY_BACKUP_IDENTITY_BYTES); - let valid_location = backup.is_absolute() - && backup.file_name().is_some_and(|name| { - name.as_encoded_bytes() - .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) - }); - let backup_metadata = fs::symlink_metadata(&backup); - let expected_root_metadata = fs::symlink_metadata(expected_root); - let valid_expected_root = expected_root_metadata.as_ref().is_ok_and(|metadata| { - metadata.file_type().is_dir() - && recovery_file_identity(metadata).0.to_le_bytes() == identity[16..24] - && recovery_file_identity(metadata).1.to_le_bytes() == identity[24..32] - }); - let valid_directory = backup_metadata.as_ref().is_ok_and(|metadata| { - metadata.file_type().is_dir() - && recovery_file_identity(metadata).0.to_le_bytes() == identity[..8] - && recovery_file_identity(metadata).1.to_le_bytes() == identity[8..16] - }); - let missing_directory = backup_metadata - .as_ref() - .is_err_and(|source| source.kind() == std::io::ErrorKind::NotFound); - let owner = recovery_candidate_owner_path(&backup); - let owner_metadata = fs::symlink_metadata(&owner); - let valid_owner = owner_metadata - .as_ref() - .ok() - .and_then(|metadata| { - read_recovery_sidecar( - &owner, - metadata, - RECOVERY_BACKUP_IDENTITY_BYTES as u64, - Some(RECOVERY_BACKUP_IDENTITY_BYTES as u64), - ) - .ok() - }) - .is_some_and(|contents| contents == identity); - if !valid_location - || !valid_expected_root - || (!valid_directory && !missing_directory) - || !valid_owner - { - return Err(invalid_recovery_backup_pointer()); - } - let owner_identity = recovery_file_identity( - owner_metadata - .as_ref() - .map_err(|_| invalid_recovery_backup_pointer())?, - ); - Ok(Some(RecoveryBackup { - path: backup, - owner_identity, - pointer_identity: recovery_file_identity(&pointer_metadata), - })) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangeKind { + Unmodified, + Modified, + Added, + Deleted, + Renamed, + Copied, + Unmerged, + Untracked, + Ignored, + Unknown(char), } #[cfg(test)] -fn recovery_backup_from_pointer(expected_root: &Path) -> Result, GitError> { - Ok(recovery_backup_record_from_pointer(expected_root)?.map(|backup| backup.path)) +fn parse_status(input: &str) -> Result { + parse_status_bytes(input.as_bytes()) } -fn ensure_no_retained_recovery_generation(root: &Path) -> Result<(), GitError> { - let parent = root.parent().ok_or_else(|| { - recovery_transaction_blocked("repository root has no parent for retained recovery checks") - })?; - let root_identity = recovery_path_identity(root)?; - let mut entries = 0_usize; - for entry in fs::read_dir(parent).map_err(|source| { - recovery_transaction_io("inspect retained recovery generations", source) - })? { - let entry = entry.map_err(|source| { - recovery_transaction_io("inspect retained recovery generation", source) - })?; - entries = entries.saturating_add(1); - if entries > MAX_RECOVERY_GENERATION_ENTRIES { - return Err(recovery_transaction_blocked( - "too many sibling entries to validate retained recovery generations", - )); - } - let path = entry.path(); - if !entry - .file_name() - .as_encoded_bytes() - .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) - || !entry - .file_type() - .map_err(|source| { - recovery_transaction_io("inspect retained recovery generation", source) - })? - .is_dir() - { +fn parse_status_bytes(input: &[u8]) -> Result { + let mut branch = BranchState::default(); + let mut oid = None; + let mut entries = Vec::new(); + let nul_delimited = input.contains(&0); + let mut records: Box + '_> = if nul_delimited { + Box::new( + input + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()), + ) + } else { + Box::new( + input + .split(|byte| *byte == b'\n') + .filter(|record| !record.is_empty()), + ) + }; + + while let Some(line) = records.next() { + if let Some(value) = strip_bytes_prefix(line, b"# branch.oid ") { + let value = parse_utf8(value, "branch oid")?; + oid = Some(value.to_owned()); + if value == "(initial)" { + branch.unborn = true; + } continue; } - let candidate_identity = match recovery_path_identity(&path) { - Ok(identity) => identity, - Err(_) if !path.exists() => continue, - Err(error) => return Err(error), - }; - let owner = recovery_candidate_owner_path(&path); - let Ok(metadata) = fs::symlink_metadata(&owner) else { - continue; - }; - let Ok(identity) = read_recovery_sidecar( - &owner, - &metadata, - RECOVERY_BACKUP_IDENTITY_BYTES as u64, - Some(RECOVERY_BACKUP_IDENTITY_BYTES as u64), - ) else { + + if let Some(value) = strip_bytes_prefix(line, b"# branch.head ") { + let value = parse_utf8(value, "branch head")?; + branch.head = if value == "(detached)" { + Head::Detached(oid.clone().unwrap_or_default()) + } else { + Head::Branch(value.to_owned()) + }; continue; - }; - let root_first = root_identity.0.to_le_bytes() == identity[..8] - && root_identity.1.to_le_bytes() == identity[8..16] - && candidate_identity.0.to_le_bytes() == identity[16..24] - && candidate_identity.1.to_le_bytes() == identity[24..32]; - let candidate_first = candidate_identity.0.to_le_bytes() == identity[..8] - && candidate_identity.1.to_le_bytes() == identity[8..16] - && root_identity.0.to_le_bytes() == identity[16..24] - && root_identity.1.to_le_bytes() == identity[24..32]; - let mut expected_owner = Sha256::new(); - expected_owner.update(root.as_os_str().as_encoded_bytes()); - expected_owner.update(path.as_os_str().as_encoded_bytes()); - expected_owner.update(&identity[..32]); - if (root_first || candidate_first) - && expected_owner.finalize().as_slice() == &identity[32..] - { - return Err(recovery_transaction_blocked(format!( - "a previous repository generation is retained at {}; inspect and move or delete it before retrying recovery", - path.display() - ))); } - } - Ok(()) -} -fn read_recovery_sidecar( - path: &Path, - expected: &fs::Metadata, - max_bytes: u64, - exact_bytes: Option, -) -> Result, GitError> { - if !expected.file_type().is_file() - || expected.len() > max_bytes - || exact_bytes.is_some_and(|bytes| expected.len() != bytes) - { - return Err(invalid_recovery_backup_pointer()); - } - let (mut file, opened) = open_recovery_regular_file(path, path, expected)?; - let mut contents = Vec::with_capacity(opened.len() as usize); - (&mut file) - .take(max_bytes.saturating_add(1)) - .read_to_end(&mut contents) - .map_err(|source| { - recovery_transaction_io("read retained recovery backup metadata", source) - })?; - if contents.len() as u64 != opened.len() || contents.len() as u64 > max_bytes { - return Err(invalid_recovery_backup_pointer()); - } - ensure_recovery_regular_file_path_unchanged(path, path, &opened)?; - Ok(contents) -} - -fn invalid_recovery_backup_pointer() -> GitError { - recovery_transaction_blocked( - "the retained recovery backup pointer is invalid; refusing to remove it", - ) -} - -fn remove_previous_recovery_backup(root: &Path) -> Result<(), GitError> { - let Some(backup) = recovery_backup_record_from_pointer(root)? else { - return Ok(()); - }; - if backup.path.exists() { - return Err(recovery_transaction_blocked(format!( - "a previous repository generation is retained at {}; inspect and move or delete it before retrying recovery", - backup.path.display() - ))); - } - remove_recovery_file( - &recovery_candidate_owner_path(&backup.path), - backup.owner_identity, - )?; - remove_recovery_file( - &recovery_backup_pointer_path(root)?, - backup.pointer_identity, - )?; - Ok(()) -} - -fn repository_local_recovery_config( - raw: Vec, - root: &Path, - git_dir: &Path, -) -> Result, GitError> { - let mut fields = raw.split(|byte| *byte == 0).collect::>(); - if fields.last() == Some(&b"".as_slice()) { - fields.pop(); - } - if fields.len() % 3 != 0 { - return Err(recovery_transaction_blocked( - "effective recovery configuration has an invalid record format", - )); - } - let allowed = [git_dir.join("config"), git_dir.join("config.worktree")]; - let mut config = Vec::with_capacity(raw.len()); - for field in fields.chunks_exact(3) { - let Some(origin) = field[1].strip_prefix(b"file:") else { - return Err(recovery_transaction_blocked( - "atomic recovery requires configuration stored in the repository", - )); - }; - let origin_path = path_from_bytes(origin); - let origin_path = if origin_path.is_absolute() { - origin_path - } else { - root.join(origin_path) - }; - if !allowed.iter().any(|path| path == &origin_path) { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support configuration included from {}", - origin_path.display() - ))); - } - let key = field[2] - .split(|byte| *byte == b'\n') - .next() - .unwrap_or_default(); - if key.eq_ignore_ascii_case(b"core.attributesfile") { - return Err(recovery_transaction_blocked( - "atomic recovery does not support core.attributesFile; user attributes are pinned off", - )); + if let Some(value) = strip_bytes_prefix(line, b"# branch.upstream ") { + let value = parse_utf8(value, "branch upstream")?; + branch.upstream = Some(value.to_owned()); + continue; } - config.extend_from_slice(field[0]); - config.push(0); - config.extend_from_slice(field[2]); - config.push(0); - } - Ok(config) -} -fn isolate_recovery_git_environment(command: &mut Command) { - for variable in RECOVERY_CONFIG_ENVIRONMENT { - command.env_remove(variable); - } - command - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_ATTR_NOSYSTEM", "1"); -} + if let Some(value) = strip_bytes_prefix(line, b"# branch.ab ") { + let value = parse_utf8(value, "branch ahead/behind")?; + let (ahead, behind) = parse_ahead_behind(value)?; + branch.ahead = ahead; + branch.behind = behind; + continue; + } -fn ensure_recovery_environment_isolated() -> Result<(), GitError> { - if let Some(variable) = RECOVERY_GIT_ENVIRONMENT - .iter() - .find(|variable| env::var_os(variable).is_some()) - { - return Err(recovery_transaction_blocked(format!( - "atomic recovery does not support inherited {variable} repository state" - ))); - } - Ok(()) -} + if let Some(path) = strip_bytes_prefix(line, b"? ") { + entries.push(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmodified, + worktree: ChangeKind::Untracked, + entry_type: StatusEntryType::Untracked, + }); + continue; + } -fn append_recovery_backup_notice(output: &mut String, backup: &Path) { - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); - } - output.push_str(&format!( - "bitbygit: previous repository generation retained at {}; inspect and move or delete it before another recovery\n", - backup.display() - )); -} + if let Some(path) = strip_bytes_prefix(line, b"! ") { + entries.push(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmodified, + worktree: ChangeKind::Ignored, + entry_type: StatusEntryType::Ignored, + }); + continue; + } -#[cfg(target_os = "linux")] -fn ensure_recovery_platform_capabilities(parent: &Path) -> Result<(), GitError> { - let (source, source_identity) = create_recovery_probe_directory(parent)?; - let (target, target_identity) = match create_recovery_probe_directory(parent) { - Ok(target) => target, - Err(error) => { - let _ = remove_recovery_directory(&source, source_identity); - return Err(error); + if line.starts_with(b"1 ") { + entries.push(parse_ordinary_entry(line)?); + continue; } - }; - let mut exchanged = false; - let result: Result<(), GitError> = (|| { - let _mount_id = recovery_mount_id(&source, Path::new("capability probe"))?; - let mut command = Command::new(recovery_probe_program(parent)); - command - .args([ - "--user", - "--map-root-user", - "--mount", - "--pid", - "--kill-child=SIGKILL", - "--fork", - "sh", - "-c", - "mount --bind \"$1\" \"$2\"", - "bitbygit-recovery-capability", - ]) - .arg(&source) - .arg(&target); - let output = run_bounded_recovery_process( - &mut command, - vec!["unshare".to_owned(), "recovery-capability-probe".to_owned()], - recovery_probe_duration(parent), - "capability probe", - None, - ) - .map_err(recovery_capability_unavailable)?; - if !output.status.success() { - let detail = if output.stderr.is_empty() { - format!("status {}", output.status) + + if line.starts_with(b"2 ") { + let original_path = if nul_delimited { + Some( + records + .next() + .ok_or_else(|| parse_error("renamed entry missing original path"))?, + ) } else { - String::from_utf8_lossy(&output.stderr).trim().to_owned() + None }; - return Err(recovery_capability_unavailable(format!( - "the Linux user/mount namespace probe failed: {detail}" - ))); + entries.push(parse_renamed_entry(line, original_path)?); + continue; } - atomic_exchange_directories(&source, &target).map_err(|error| { - recovery_capability_unavailable(format!( - "same-filesystem atomic directory exchange is not available: {error}" - )) - })?; - exchanged = true; - Err(recovery_capability_unavailable( - "the platform cannot make a writable speculative repository unreachable to same-identity host processes or condition directory exchange on inode identity", - )) - })(); - run_recovery_probe_cleanup_hook(parent); - let source_cleanup = remove_recovery_directory( - &source, - if exchanged { - target_identity - } else { - source_identity - }, - ); - let target_cleanup = remove_recovery_directory( - &target, - if exchanged { - source_identity - } else { - target_identity - }, - ); - result.and(source_cleanup).and(target_cleanup) -} -#[cfg(test)] -static RECOVERY_PROBE_PROGRAMS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); + if line.starts_with(b"u ") { + entries.push(parse_conflict_entry(line)?); + continue; + } -#[cfg(test)] -fn recovery_probe_program(parent: &Path) -> OsString { - if let Ok(mut programs) = RECOVERY_PROBE_PROGRAMS.lock() - && let Some(index) = programs.iter().position(|(target, _)| target == parent) - { - return programs.swap_remove(index).1; + if !line.starts_with(b"# ") { + return Err(parse_error(&format!( + "unknown porcelain v2 record: {}", + String::from_utf8_lossy(line) + ))); + } } - OsString::from("unshare") -} - -#[cfg(not(test))] -fn recovery_probe_program(_parent: &Path) -> OsString { - OsString::from("unshare") -} - -#[cfg(test)] -static RECOVERY_PROBE_DURATIONS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); -#[cfg(test)] -fn recovery_probe_duration(parent: &Path) -> std::time::Duration { - if let Ok(mut durations) = RECOVERY_PROBE_DURATIONS.lock() - && let Some(index) = durations.iter().position(|(target, _)| target == parent) - { - return durations.swap_remove(index).1; - } - MAX_RECOVERY_COMMAND_DURATION + Ok(WorktreeStatus { + branch, + entries, + operation: None, + }) } -#[cfg(not(test))] -fn recovery_probe_duration(_parent: &Path) -> std::time::Duration { - MAX_RECOVERY_COMMAND_DURATION +fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> { + let mut parts = value.split_whitespace(); + let ahead = parts + .next() + .ok_or_else(|| parse_error("missing ahead count"))? + .strip_prefix('+') + .ok_or_else(|| parse_error("ahead count must start with +"))? + .parse::() + .map_err(|_| parse_error("ahead count is not a number"))?; + let behind = parts + .next() + .ok_or_else(|| parse_error("missing behind count"))? + .strip_prefix('-') + .ok_or_else(|| parse_error("behind count must start with -"))? + .parse::() + .map_err(|_| parse_error("behind count is not a number"))?; + Ok((ahead, behind)) } -#[cfg(not(target_os = "linux"))] -fn ensure_recovery_platform_capabilities(_parent: &Path) -> Result<(), GitError> { - Err(recovery_capability_unavailable( - "Linux user/mount namespaces and atomic directory exchange are required", - )) -} +fn parse_ordinary_entry(line: &[u8]) -> Result { + let parts = splitn_bytes(line, b' ', 9); + let xy = parts + .get(1) + .copied() + .ok_or_else(|| parse_error("ordinary entry missing status"))?; + let path = parts + .get(8) + .copied() + .ok_or_else(|| parse_error("ordinary entry missing path"))?; + let (index, worktree) = parse_xy(xy)?; -fn create_recovery_probe_directory(parent: &Path) -> Result<(PathBuf, (u64, u64)), GitError> { - for attempt in 0..100_u32 { - let candidate = parent.join(format!( - ".bitbygit-recovery-capability-{}-{}-{attempt}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - match fs::create_dir(&candidate) { - Ok(()) => { - let identity = recovery_path_identity(&candidate)?; - return Ok((candidate, identity)); - } - Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(source) => { - return Err(recovery_capability_unavailable(format!( - "a same-filesystem capability probe directory could not be created: {source}" - ))); - } - } - } - Err(recovery_capability_unavailable( - "a same-filesystem capability probe directory could not be reserved", - )) + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index, + worktree, + entry_type: StatusEntryType::Ordinary, + }) } -#[cfg(target_os = "linux")] -fn atomic_exchange_directories(left: &Path, right: &Path) -> Result<(), GitError> { - use rustix::fs::{CWD, RenameFlags, renameat_with}; +fn parse_renamed_entry(line: &[u8], original_path: Option<&[u8]>) -> Result { + let parts = splitn_bytes(line, b' ', 10); + let xy = parts + .get(1) + .copied() + .ok_or_else(|| parse_error("renamed entry missing status"))?; + let path = parts + .get(9) + .copied() + .ok_or_else(|| parse_error("renamed entry missing path"))?; + let (path, original_path) = match original_path { + Some(original_path) => (path, original_path), + None => split_once_byte(path, b'\t') + .ok_or_else(|| parse_error("renamed entry missing original path"))?, + }; + let (index, worktree) = parse_xy(xy)?; - renameat_with(CWD, left, CWD, right, RenameFlags::EXCHANGE).map_err(|source| { - recovery_transaction_blocked(format!("atomic directory exchange failed: {source}")) + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: Some(path_from_bytes(original_path)), + index, + worktree, + entry_type: if index == ChangeKind::Copied || worktree == ChangeKind::Copied { + StatusEntryType::Copied + } else { + StatusEntryType::Renamed + }, }) } -#[cfg(not(target_os = "linux"))] -fn atomic_exchange_directories(_left: &Path, _right: &Path) -> Result<(), GitError> { - Err(recovery_transaction_blocked( - "atomic recovery is not supported on this platform", - )) -} - -fn recovery_transaction_io(action: &str, source: std::io::Error) -> GitError { - recovery_transaction_blocked(format!("atomic recovery could not {action}: {source}")) -} +fn parse_conflict_entry(line: &[u8]) -> Result { + let parts = splitn_bytes(line, b' ', 11); + let path = parts + .get(10) + .copied() + .ok_or_else(|| parse_error("conflict entry missing path"))?; -fn recovery_transaction_blocked(message: impl Into) -> GitError { - GitError::Blocked { - message: message.into(), - } + Ok(StatusEntry { + path: path_from_bytes(path), + original_path: None, + index: ChangeKind::Unmerged, + worktree: ChangeKind::Unmerged, + entry_type: StatusEntryType::Conflict, + }) } -fn recovery_capability_unavailable(detail: impl Display) -> GitError { - recovery_transaction_blocked(format!("{RECOVERY_CAPABILITY_UNAVAILABLE}: {detail}")) +fn parse_xy(value: &[u8]) -> Result<(ChangeKind, ChangeKind), GitError> { + let index = value + .first() + .copied() + .ok_or_else(|| parse_error("missing index status"))?; + let worktree = value + .get(1) + .copied() + .ok_or_else(|| parse_error("missing worktree status"))?; + Ok((change_kind(index), change_kind(worktree))) } -#[cfg(test)] -static RECOVERY_CAPABILITY_FAILURES: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_capability_hook(root: &Path) -> Result<(), GitError> { - if let Ok(mut failures) = RECOVERY_CAPABILITY_FAILURES.lock() - && let Some(index) = failures.iter().position(|target| target == root) - { - failures.swap_remove(index); - return Err(recovery_capability_unavailable( - "test platform capability failure", - )); +fn change_kind(value: u8) -> ChangeKind { + match value { + b'.' => ChangeKind::Unmodified, + b'M' => ChangeKind::Modified, + b'A' => ChangeKind::Added, + b'D' => ChangeKind::Deleted, + b'R' => ChangeKind::Renamed, + b'C' => ChangeKind::Copied, + b'U' => ChangeKind::Unmerged, + b'?' => ChangeKind::Untracked, + b'!' => ChangeKind::Ignored, + other => ChangeKind::Unknown(char::from(other)), } - Ok(()) -} - -#[cfg(not(test))] -fn run_recovery_capability_hook(_root: &Path) -> Result<(), GitError> { - Ok(()) } -#[cfg(test)] -static RECOVERY_PROBE_CLEANUP_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_probe_cleanup_hook(parent: &Path) { - if let Ok(mut hooks) = RECOVERY_PROBE_CLEANUP_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == parent) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } -} +fn parse_remotes(input: &str) -> Vec { + let mut remotes = BTreeMap::::new(); -#[cfg(not(test))] -fn run_recovery_probe_cleanup_hook(_parent: &Path) {} + for line in input.lines() { + let Some((name, rest)) = line.split_once('\t') else { + continue; + }; + let Some((url, kind)) = rest.rsplit_once(' ') else { + continue; + }; -#[cfg(test)] -static RECOVERY_PREPARE_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); + let remote = remotes.entry(name.to_owned()).or_insert_with(|| Remote { + name: name.to_owned(), + fetch_url: None, + push_url: None, + }); -#[cfg(test)] -fn run_recovery_prepare_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_PREPARE_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); + match kind { + "(fetch)" => remote.fetch_url = Some(url.to_owned()), + "(push)" => remote.push_url = Some(url.to_owned()), + _ => {} + } } -} - -#[cfg(not(test))] -fn run_recovery_prepare_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_EXECUTION_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); -#[cfg(test)] -fn run_recovery_execution_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_EXECUTION_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } + remotes.into_values().collect() } -#[cfg(not(test))] -fn run_recovery_execution_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_PROMOTION_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_promotion_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_PROMOTION_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); +fn parse_branches(input: &str) -> Result, GitError> { + let mut branches = Vec::new(); + for line in input.lines().filter(|line| !line.trim().is_empty()) { + let branch = parse_branch_line(line)?; + if branch.kind == BranchKind::Remote && branch.name.ends_with("/HEAD") { + continue; + } + branches.push(branch); } + Ok(branches) } -#[cfg(not(test))] -fn run_recovery_promotion_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_POST_EXCHANGE_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_post_exchange_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_POST_EXCHANGE_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } +fn parse_branch_line(line: &str) -> Result { + let fields = line.split('\0').collect::>(); + let [reference, oid, upstream, head] = fields.as_slice() else { + return Err(GitError::Parse { + message: "git branch list output has unexpected fields".to_owned(), + }); + }; + let (kind, name) = if let Some(name) = reference.strip_prefix("refs/heads/") { + (BranchKind::Local, name) + } else if let Some(name) = reference.strip_prefix("refs/remotes/") { + (BranchKind::Remote, name) + } else { + return Err(GitError::Parse { + message: format!("unsupported branch reference: {reference}"), + }); + }; + Ok(BranchInfo { + name: name.to_owned(), + reference: (*reference).to_owned(), + oid: (*oid).to_owned(), + upstream: (!upstream.is_empty()).then(|| (*upstream).to_owned()), + current: *head == "*", + kind, + }) } -#[cfg(not(test))] -fn run_recovery_post_exchange_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_INSTALLED_CAPTURE_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_installed_capture_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_INSTALLED_CAPTURE_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } +fn local_name_for_remote_branch(name: &str) -> Option<&str> { + name.split_once('/') + .map(|(_remote, branch)| branch) + .filter(|branch| !branch.is_empty()) } -#[cfg(not(test))] -fn run_recovery_installed_capture_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_CLEANUP_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_cleanup_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_CLEANUP_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } +fn strip_byte_line_ending(value: &[u8]) -> &[u8] { + value.strip_suffix(b"\n").unwrap_or(value) } -#[cfg(not(test))] -fn run_recovery_cleanup_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_SIDECAR_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_sidecar_hook(path: &Path) { - if let Ok(mut hooks) = RECOVERY_SIDECAR_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == path) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } +fn strip_bytes_prefix<'a>(value: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> { + value.strip_prefix(prefix) } -#[cfg(not(test))] -fn run_recovery_sidecar_hook(_path: &Path) {} - -type RecoveryCopyLimits = (usize, usize, u64); - -#[cfg(test)] -static RECOVERY_COPY_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_copy_hook(root: &Path) { - if let Ok(mut hooks) = RECOVERY_COPY_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == root) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } +fn parse_utf8<'a>(value: &'a [u8], description: &str) -> Result<&'a str, GitError> { + std::str::from_utf8(value).map_err(|_| parse_error(&format!("{description} is not UTF-8"))) } -#[cfg(not(test))] -fn run_recovery_copy_hook(_root: &Path) {} - -#[cfg(test)] -static RECOVERY_COPY_PROGRAMS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn recovery_copy_program(root: &Path) -> OsString { - if let Ok(mut programs) = RECOVERY_COPY_PROGRAMS.lock() - && let Some(index) = programs.iter().position(|(target, _)| target == root) - { - return programs.swap_remove(index).1; - } - OsString::from("cp") +fn splitn_bytes(value: &[u8], delimiter: u8, count: usize) -> Vec<&[u8]> { + value.splitn(count, |byte| *byte == delimiter).collect() } -#[cfg(not(test))] -fn recovery_copy_program(_root: &Path) -> OsString { - OsString::from("cp") +fn split_once_byte(value: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { + let index = value.iter().position(|byte| *byte == delimiter)?; + Some((&value[..index], &value[index + 1..])) } -#[cfg(test)] -static RECOVERY_COPY_DURATIONS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} -#[cfg(test)] -fn recovery_copy_duration(root: &Path) -> std::time::Duration { - if let Ok(mut durations) = RECOVERY_COPY_DURATIONS.lock() - && let Some(index) = durations.iter().position(|(target, _)| target == root) - { - return durations.swap_remove(index).1; - } - MAX_RECOVERY_COMMAND_DURATION +#[cfg(unix)] +fn path_from_bytes(value: &[u8]) -> PathBuf { + PathBuf::from(OsString::from_vec(value.to_vec())) } -#[cfg(not(test))] -fn recovery_copy_duration(_root: &Path) -> std::time::Duration { - MAX_RECOVERY_COMMAND_DURATION +#[cfg(not(unix))] +fn path_from_bytes(value: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(value).into_owned()) } -#[cfg(test)] -static RECOVERY_COPY_LIMIT_OVERRIDES: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); +fn parse_error(message: &str) -> GitError { + GitError::Parse { + message: message.to_owned(), + } +} -#[cfg(test)] -fn recovery_copy_limits(root: &Path) -> RecoveryCopyLimits { - RECOVERY_COPY_LIMIT_OVERRIDES - .lock() - .ok() - .and_then(|limits| { - limits - .iter() - .find(|(target, _)| target == root) - .map(|(_, limits)| *limits) - }) - .unwrap_or(( - MAX_RECOVERY_GENERATION_ENTRIES, - MAX_RECOVERY_GENERATION_PATH_BYTES, - MAX_RECOVERY_GENERATION_FILE_BYTES, - )) +fn remote_tracking_ref(remote: &str, branch: &str) -> String { + format!("refs/remotes/{remote}/{branch}") } -#[cfg(not(test))] -fn recovery_copy_limits(_root: &Path) -> RecoveryCopyLimits { - ( - MAX_RECOVERY_GENERATION_ENTRIES, - MAX_RECOVERY_GENERATION_PATH_BYTES, - MAX_RECOVERY_GENERATION_FILE_BYTES, +fn force_with_lease_arg(branch: &str, expected_remote_oid: Option<&str>) -> String { + format!( + "--force-with-lease=refs/heads/{branch}:{}", + expected_remote_oid.unwrap_or("") ) } -fn ensure_recovery_copy_bounds( - root: &Path, - (entry_limit, path_limit, file_limit): RecoveryCopyLimits, -) -> Result<(), GitError> { - let mut pending = vec![PathBuf::new()]; - let mut entries = 0_usize; - let mut path_bytes = 0_usize; - let mut file_bytes = 0_u64; - while let Some(relative_dir) = pending.pop() { - let directory = root.join(&relative_dir); - for child in fs::read_dir(&directory) - .map_err(|source| recovery_transaction_io("inspect repository copy", source))? - { - let child = child - .map_err(|source| recovery_transaction_io("inspect repository copy", source))?; - let relative = relative_dir.join(child.file_name()); - entries = entries.saturating_add(1); - path_bytes = path_bytes.saturating_add(relative.as_os_str().as_encoded_bytes().len()); - if entries > entry_limit || path_bytes > path_limit { - return Err(recovery_transaction_blocked( - "isolated repository copy exceeds atomic recovery entry or path bounds", - )); - } - let metadata = fs::symlink_metadata(child.path()) - .map_err(|source| recovery_transaction_io("inspect repository copy", source))?; - if metadata.file_type().is_dir() { - pending.push(relative); - } else if metadata.file_type().is_file() { - file_bytes = file_bytes.saturating_add(metadata.len()); - if file_bytes > file_limit { - return Err(recovery_transaction_blocked( - "isolated repository copy exceeds atomic recovery content bound", - )); - } - } - } +fn combine_outputs(first: GitOutput, second: GitOutput) -> GitOutput { + let stdout = [first.stdout.trim(), second.stdout.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let stderr = [first.stderr.trim(), second.stderr.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + GitOutput { + status: second.status, + stdout, + stderr, } - Ok(()) } -#[cfg(test)] -static RECOVERY_COMMAND_DURATIONS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn recovery_command_duration(root: &Path) -> std::time::Duration { - if let Ok(mut durations) = RECOVERY_COMMAND_DURATIONS.lock() - && let Some(index) = durations.iter().position(|(target, _)| target == root) - { - return durations.swap_remove(index).1; - } - MAX_RECOVERY_COMMAND_DURATION -} +#[cfg(unix)] +fn hook_is_enabled(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; -#[cfg(not(test))] -fn recovery_command_duration(_root: &Path) -> std::time::Duration { - MAX_RECOVERY_COMMAND_DURATION + fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) } -#[derive(Default)] -struct RecoveryCapture { - output_bytes: usize, - retained_bytes: usize, - file_bytes_read: u64, - metadata_bytes: usize, - metadata_entries: usize, - subprocesses: usize, +#[cfg(not(unix))] +fn hook_is_enabled(path: &Path) -> bool { + fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) } -impl RecoveryCapture { - fn required(&mut self, git: &Git, args: &[&str]) -> Result, GitError> { - self.command(git, args.iter().map(OsString::from).collect(), false)? - .ok_or_else(|| GitError::Parse { - message: "required recovery guard command returned no output".to_owned(), - }) - } - - fn optional(&mut self, git: &Git, args: &[&str]) -> Result>, GitError> { - self.command(git, args.iter().map(OsString::from).collect(), true) - } - - fn required_os(&mut self, git: &Git, args: Vec) -> Result, GitError> { - self.command(git, args, false)? - .ok_or_else(|| GitError::Parse { - message: "required recovery guard command returned no output".to_owned(), - }) - } - - fn command( - &mut self, - git: &Git, - args: Vec, - allow_missing: bool, - ) -> Result>, GitError> { - self.subprocesses += 1; - if self.subprocesses > MAX_RECOVERY_SUBPROCESSES { - return Err(recovery_bound_error(format!( - "subprocess count exceeds {MAX_RECOVERY_SUBPROCESSES}" - ))); - } - let display_args = args - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - let mut command = Command::new("git"); - command - .current_dir(&git.cwd) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_ASKPASS", "") - .env("SSH_ASKPASS", "") - .env("SSH_ASKPASS_REQUIRE", "never") - .args(&args); - isolate_recovery_git_environment(&mut command); - let output = run_bounded_recovery_command( - &mut command, - display_args.clone(), - MAX_RECOVERY_COMMAND_DURATION, - )?; - let bytes = output.stdout.len().saturating_add(output.stderr.len()); - self.output_bytes = self.output_bytes.saturating_add(bytes); - if self.output_bytes > MAX_RECOVERY_OUTPUT_BYTES { - return Err(recovery_bound_error(format!( - "Git output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" - ))); - } - if output.status.success() { - return Ok(Some(output.stdout)); - } - if allow_missing && output.status.code() == Some(1) { - return Ok(None); - } - Err(GitError::GitFailed { - args: display_args, - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } - - fn retain(&mut self, bytes: usize, description: &str) -> Result<(), GitError> { - self.retained_bytes = self.retained_bytes.saturating_add(bytes); - if self.retained_bytes > MAX_RECOVERY_RETAINED_BYTES { - return Err(recovery_bound_error(format!( - "retained memory for {description} exceeds {MAX_RECOVERY_RETAINED_BYTES} bytes" - ))); - } - Ok(()) - } +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::process::Command; + use std::sync::atomic::{AtomicUsize, Ordering}; - fn reserve_file_bytes(&mut self, bytes: u64) -> Result<(), GitError> { - self.file_bytes_read = self.file_bytes_read.saturating_add(bytes); - if self.file_bytes_read > MAX_RECOVERY_FILE_BYTES_READ { - return Err(recovery_bound_error(format!( - "file content bytes read exceed {MAX_RECOVERY_FILE_BYTES_READ}" - ))); - } - Ok(()) - } + #[cfg(unix)] + use std::os::unix::ffi::{OsStrExt, OsStringExt}; - fn reserve_metadata_entry(&mut self) -> Result<(), GitError> { - if self.metadata_entries == MAX_RECOVERY_METADATA_ENTRIES { - return Err(recovery_bound_error(format!( - "metadata entries exceed {MAX_RECOVERY_METADATA_ENTRIES}" - ))); - } - self.metadata_entries += 1; - Ok(()) - } - - fn reserve_metadata_bytes(&mut self, bytes: usize) -> Result<(), GitError> { - self.metadata_bytes = self.metadata_bytes.saturating_add(bytes); - if self.metadata_bytes > MAX_RECOVERY_METADATA_BYTES { - return Err(recovery_bound_error(format!( - "metadata bytes exceed {MAX_RECOVERY_METADATA_BYTES}" - ))); - } - self.retain(bytes.saturating_add(80), "recovery metadata") - } -} - -fn run_bounded_recovery_command( - command: &mut Command, - args: Vec, - duration: std::time::Duration, -) -> Result { - run_bounded_recovery_process(command, args, duration, "Git", None) -} - -fn run_bounded_recovery_process( - command: &mut Command, - args: Vec, - duration: std::time::Duration, - description: &str, - mut progress_guard: Option<&mut dyn FnMut() -> Result<(), GitError>>, -) -> Result { - #[cfg(target_os = "linux")] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = command.spawn().map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - let stdout = child.stdout.take().ok_or_else(|| GitError::Io { - args: args.clone(), - source: std::io::Error::other("recovery command could not capture stdout"), - })?; - let stderr = child.stderr.take().ok_or_else(|| GitError::Io { - args: args.clone(), - source: std::io::Error::other("recovery command could not capture stderr"), - })?; - #[cfg(target_os = "linux")] - { - if let Err(source) = configure_recovery_pipe_nonblocking(&stdout) - .and_then(|()| configure_recovery_pipe_nonblocking(&stderr)) - { - let _ = terminate_recovery_process_group(&mut child); - let _ = child.wait(); - return Err(GitError::Io { args, source }); - } - } - let output_bytes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let stop_readers = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let stdout_bytes = std::sync::Arc::clone(&output_bytes); - let stderr_bytes = std::sync::Arc::clone(&output_bytes); - let stdout_stop = std::sync::Arc::clone(&stop_readers); - let stderr_stop = std::sync::Arc::clone(&stop_readers); - let stdout_reader = - std::thread::spawn(move || read_bounded_recovery_output(stdout, stdout_bytes, stdout_stop)); - let stderr_reader = - std::thread::spawn(move || read_bounded_recovery_output(stderr, stderr_bytes, stderr_stop)); - let started = std::time::Instant::now(); - let mut deadline_exceeded = false; - let mut guard_error = None; - let mut last_guard = started; - let status = loop { - if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { - #[cfg(target_os = "linux")] - let termination = terminate_recovery_process_group(&mut child); - #[cfg(not(target_os = "linux"))] - let termination = child.kill(); - let status = child.wait(); - break termination.and(status); - } - if started.elapsed() >= duration { - deadline_exceeded = true; - #[cfg(target_os = "linux")] - let termination = terminate_recovery_process_group(&mut child); - #[cfg(not(target_os = "linux"))] - let termination = child.kill(); - let status = child.wait(); - break termination.and(status); - } - if last_guard.elapsed() >= std::time::Duration::from_millis(25) { - last_guard = std::time::Instant::now(); - if let Some(guard) = progress_guard.as_mut() - && let Err(error) = guard() - { - guard_error = Some(error); - #[cfg(target_os = "linux")] - let termination = terminate_recovery_process_group(&mut child); - #[cfg(not(target_os = "linux"))] - let termination = child.kill(); - let status = child.wait(); - break termination.and(status); - } - } - match child.try_wait() { - Ok(Some(status)) => break Ok(status), - Ok(None) => std::thread::sleep(std::time::Duration::from_millis(1)), - Err(source) => break Err(source), - } - }; - #[cfg(target_os = "linux")] - let post_exit_termination = terminate_recovery_process_group(&mut child); - #[cfg(not(target_os = "linux"))] - let post_exit_termination = Ok(()); - stop_readers.store(true, std::sync::atomic::Ordering::Release); - let status = status.map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - let stdout = stdout_reader - .join() - .map_err(|_| GitError::Io { - args: args.clone(), - source: std::io::Error::other("recovery stdout reader panicked"), - })? - .map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - post_exit_termination.map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - let stderr = stderr_reader - .join() - .map_err(|_| GitError::Io { - args: args.clone(), - source: std::io::Error::other("recovery stderr reader panicked"), - })? - .map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - if let Some(error) = guard_error { - return Err(error); - } - if output_bytes.load(std::sync::atomic::Ordering::Relaxed) > MAX_RECOVERY_OUTPUT_BYTES { - return Err(recovery_bound_error(format!( - "{description} output bytes exceed {MAX_RECOVERY_OUTPUT_BYTES}" - ))); - } - if deadline_exceeded { - return Err(recovery_bound_error(format!( - "{description} exceeded its {}ms execution deadline", - duration.as_millis() - ))); - } - if let Some(guard) = progress_guard.as_mut() { - guard()?; - } - Ok(RawProcessOutput { - args, - status, - stdout, - stderr, - }) -} - -#[cfg(target_os = "linux")] -fn terminate_recovery_process_group(child: &mut std::process::Child) -> std::io::Result<()> { - let raw_pid = i32::try_from(child.id()) - .map_err(|_| std::io::Error::other("recovery process id is out of range"))?; - let pid = rustix::process::Pid::from_raw(raw_pid) - .ok_or_else(|| std::io::Error::other("recovery process id is invalid"))?; - match rustix::process::kill_process_group(pid, rustix::process::Signal::Kill) { - Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()), - Err(source) => { - let _ = child.kill(); - Err(std::io::Error::from_raw_os_error(source.raw_os_error())) - } - } -} - -fn read_bounded_recovery_output( - mut stream: impl Read, - total: std::sync::Arc, - stop: std::sync::Arc, -) -> std::io::Result> { - let mut retained = Vec::new(); - let mut buffer = [0_u8; 16 * 1024]; - loop { - let read = match stream.read(&mut buffer) { - Ok(read) => read, - Err(source) if source.kind() == std::io::ErrorKind::Interrupted => continue, - Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => { - if stop.load(std::sync::atomic::Ordering::Acquire) { - return Ok(retained); - } - std::thread::sleep(std::time::Duration::from_millis(1)); - continue; - } - Err(source) => return Err(source), - }; - if read == 0 { - return Ok(retained); - } - let previous = total.fetch_add(read, std::sync::atomic::Ordering::Relaxed); - if previous <= MAX_RECOVERY_OUTPUT_BYTES { - let keep = read.min(MAX_RECOVERY_OUTPUT_BYTES + 1 - previous); - retained.extend_from_slice(&buffer[..keep]); - } - if previous.saturating_add(read) > MAX_RECOVERY_OUTPUT_BYTES { - return Ok(retained); - } - } -} - -#[cfg(target_os = "linux")] -fn configure_recovery_pipe_nonblocking(pipe: &impl std::os::fd::AsFd) -> std::io::Result<()> { - let flags = rustix::fs::fcntl_getfl(pipe) - .map_err(|source| std::io::Error::from_raw_os_error(source.raw_os_error()))?; - rustix::fs::fcntl_setfl(pipe, flags | rustix::fs::OFlags::NONBLOCK) - .map_err(|source| std::io::Error::from_raw_os_error(source.raw_os_error())) -} - -fn recovery_bound_error(detail: String) -> GitError { - GitError::Blocked { - message: format!("recovery is blocked because the recovery guard {detail}"), - } -} - -fn parse_recovery_changed_paths( - output: &[u8], - paths: &mut BTreeSet, -) -> Result<(), GitError> { - let mut expected_paths = 0; - for record in output.split(|byte| *byte == 0) { - if expected_paths > 0 { - if paths.len() == MAX_RECOVERY_RELEVANT_FILES { - return Err(recovery_bound_error(format!( - "relevant file count exceeds {MAX_RECOVERY_RELEVANT_FILES}" - ))); - } - paths.insert(path_from_bytes(record)); - expected_paths -= 1; - continue; - } - let header = record.strip_prefix(b"\n").unwrap_or(record); - if !header.starts_with(b":") { - continue; - } - let status = header - .rsplit(|byte| *byte == b' ') - .next() - .and_then(|status| status.first()) - .ok_or_else(|| GitError::Parse { - message: "recovery changed-path record has no status".to_owned(), - })?; - expected_paths = usize::from(matches!(status, b'R' | b'C')) + 1; - } - if expected_paths != 0 { - return Err(GitError::Parse { - message: "recovery changed-path output ended before its path".to_owned(), - }); - } - Ok(()) -} - -fn recovery_operation_at(git_dir: &Path) -> Option { - let rebase_apply = git_dir.join("rebase-apply"); - if git_dir.join("rebase-merge").exists() - || (rebase_apply.exists() && !rebase_apply.join("applying").exists()) - { - Some(RepositoryOperation::Rebase) - } else if git_dir.join("MERGE_HEAD").exists() { - Some(RepositoryOperation::Merge) - } else { - None - } -} - -#[cfg(unix)] -fn recovery_file_mode(metadata: &fs::Metadata) -> u32 { - use std::os::unix::fs::PermissionsExt; - - metadata.permissions().mode() -} - -#[cfg(not(unix))] -fn recovery_file_mode(metadata: &fs::Metadata) -> u32 { - u32::from(metadata.permissions().readonly()) -} - -#[cfg(unix)] -fn recovery_file_identity(metadata: &fs::Metadata) -> (u64, u64) { - use std::os::unix::fs::MetadataExt; - - (metadata.dev(), metadata.ino()) -} - -fn recovery_path_identity(path: &Path) -> Result<(u64, u64), GitError> { - let metadata = fs::symlink_metadata(path) - .map_err(|source| recovery_transaction_io("identify recovery directory", source))?; - if !metadata.file_type().is_dir() { - return Err(recovery_transaction_blocked( - "recovery directory changed before its identity could be verified", - )); - } - Ok(recovery_file_identity(&metadata)) -} - -#[cfg(not(unix))] -fn recovery_file_identity(_metadata: &fs::Metadata) -> (u64, u64) { - (0, 0) -} - -fn open_recovery_regular_file( - path: &Path, - relative: &Path, - expected: &fs::Metadata, -) -> Result<(fs::File, fs::Metadata), GitError> { - run_recovery_file_open_hook(path); - - #[cfg(unix)] - let file = { - use std::os::unix::fs::OpenOptionsExt; - - fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(path) - }; - #[cfg(not(unix))] - let file = fs::File::open(path); - - let file = file.map_err(|source| GitError::Blocked { - message: format!( - "recovery is blocked because regular file {} changed before it could be opened: {source}", - relative.display() - ), - })?; - let opened = file - .metadata() - .map_err(|source| recovery_metadata_io_error(relative, source))?; - if !opened.file_type().is_file() - || opened.len() != expected.len() - || recovery_file_mode(&opened) != recovery_file_mode(expected) - || recovery_file_identity(&opened) != recovery_file_identity(expected) - { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because regular file {} changed while it was opened", - relative.display() - ), - }); - } - Ok((file, opened)) -} - -fn ensure_recovery_regular_file_path_unchanged( - path: &Path, - relative: &Path, - opened: &fs::Metadata, -) -> Result<(), GitError> { - let current = fs::symlink_metadata(path).map_err(|source| GitError::Blocked { - message: format!( - "recovery is blocked because regular file {} changed while it was read: {source}", - relative.display() - ), - })?; - if !current.file_type().is_file() - || current.len() != opened.len() - || recovery_file_mode(¤t) != recovery_file_mode(opened) - || recovery_file_identity(¤t) != recovery_file_identity(opened) - { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because regular file {} changed while it was read", - relative.display() - ), - }); - } - Ok(()) -} - -#[cfg(test)] -type RecoveryCaptureHook = Box; - -#[cfg(test)] -static RECOVERY_CAPTURE_HOOK: std::sync::Mutex> = - std::sync::Mutex::new(None); - -#[cfg(test)] -fn run_recovery_capture_hook(cwd: &Path) { - if let Ok(mut hook) = RECOVERY_CAPTURE_HOOK.lock() { - if hook.as_ref().is_some_and(|(target, _)| target == cwd) { - let Some((_, hook)) = hook.take() else { - return; - }; - hook(); - } - } -} - -#[cfg(not(test))] -fn run_recovery_capture_hook(_cwd: &Path) {} - -#[cfg(test)] -static RECOVERY_FILE_OPEN_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_file_open_hook(path: &Path) { - if let Ok(mut hooks) = RECOVERY_FILE_OPEN_HOOKS.lock() { - if let Some(index) = hooks.iter().position(|(target, _)| target == path) { - let (_, hook) = hooks.swap_remove(index); - hook(); - } - } -} - -#[cfg(not(test))] -fn run_recovery_file_open_hook(_path: &Path) {} - -#[cfg(test)] -static RECOVERY_GENERATION_FILE_READ_HOOKS: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - -#[cfg(test)] -fn run_recovery_generation_file_read_hook(path: &Path) { - if let Ok(mut hooks) = RECOVERY_GENERATION_FILE_READ_HOOKS.lock() - && let Some(index) = hooks.iter().position(|(target, _)| target == path) - { - let (_, hook) = hooks.swap_remove(index); - hook(); - } -} - -#[cfg(not(test))] -fn run_recovery_generation_file_read_hook(_path: &Path) {} - -fn recovery_metadata_file<'a>( - metadata: &'a [RecoveryMetadataEntry], - relative: &str, -) -> Option<&'a [u8]> { - metadata.iter().find_map(|entry| { - if entry.path == Path::new(relative) { - if let RecoveryMetadataValue::File { contents, .. } = &entry.value { - return Some(contents.as_slice()); - } - } - None - }) -} - -fn rebase_original_head(metadata: &[RecoveryMetadataEntry]) -> Result { - let backends = ["rebase-merge", "rebase-apply"] - .into_iter() - .filter(|backend| { - metadata.iter().any(|entry| { - entry.path == Path::new(backend) - && matches!(entry.value, RecoveryMetadataValue::Directory { .. }) - }) - }) - .collect::>(); - let [backend] = backends.as_slice() else { - return Err(GitError::Blocked { - message: "recovery is blocked because the active rebase backend is ambiguous" - .to_owned(), - }); - }; - let relative = format!("{backend}/orig-head"); - let Some(contents) = recovery_metadata_file(metadata, &relative) else { - return Err(GitError::Blocked { - message: format!("recovery is blocked because {relative} is unavailable"), - }); - }; - let oid = strip_byte_line_ending(contents); - if !matches!(oid.len(), 40 | 64) || !oid.iter().all(u8::is_ascii_hexdigit) { - return Err(GitError::Blocked { - message: format!("recovery is blocked because {relative} is not a valid object id"), - }); - } - Ok(String::from_utf8_lossy(oid).into_owned()) -} - -fn snapshot_recovery_metadata( - git_dir: &Path, - capture: &mut RecoveryCapture, -) -> Result, GitError> { - snapshot_recovery_paths( - RECOVERY_METADATA_PATHS - .iter() - .map(|relative| (PathBuf::from(relative), git_dir.join(relative))), - capture, - ) -} - -fn snapshot_recovery_worktree_attributes( - root: &Path, - capture: &mut RecoveryCapture, -) -> Result, GitError> { - let mut pending = vec![PathBuf::new()]; - let mut paths = Vec::new(); - let mut entries = 0_usize; - let mut path_bytes = 0_usize; - while let Some(relative) = pending.pop() { - for child in fs::read_dir(root.join(&relative)) - .map_err(|source| recovery_metadata_io_error(&relative, source))? - { - let child = child.map_err(|source| recovery_metadata_io_error(&relative, source))?; - let child_relative = relative.join(child.file_name()); - if child_relative == Path::new(".git") { - continue; - } - entries = entries.saturating_add(1); - path_bytes = - path_bytes.saturating_add(child_relative.as_os_str().as_encoded_bytes().len()); - if entries > MAX_RECOVERY_GENERATION_ENTRIES - || path_bytes > MAX_RECOVERY_GENERATION_PATH_BYTES - { - return Err(recovery_bound_error( - "attribute source traversal exceeds repository entry or path bounds".to_owned(), - )); - } - let metadata = fs::symlink_metadata(child.path()) - .map_err(|source| recovery_metadata_io_error(&child_relative, source))?; - if metadata.file_type().is_dir() { - pending.push(child_relative); - } else if child.file_name() == ".gitattributes" { - paths.push((Path::new("worktree").join(&child_relative), child.path())); - } - } - } - snapshot_recovery_paths(paths, capture) -} - -fn snapshot_recovery_paths( - paths: impl IntoIterator, - capture: &mut RecoveryCapture, -) -> Result, GitError> { - let mut entries = Vec::new(); - let mut pending = paths.into_iter().collect::>(); - pending.reverse(); - for _ in &pending { - capture.reserve_metadata_entry()?; - } - - while let Some((relative, path)) = pending.pop() { - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - capture.reserve_metadata_bytes(relative.as_os_str().as_encoded_bytes().len())?; - entries.push(RecoveryMetadataEntry { - path: relative, - value: RecoveryMetadataValue::Missing, - }); - continue; - } - Err(source) => return Err(recovery_metadata_io_error(&relative, source)), - }; - let file_type = metadata.file_type(); - let value = if file_type.is_dir() { - capture.reserve_metadata_bytes(relative.as_os_str().as_encoded_bytes().len())?; - RecoveryMetadataValue::Directory { - mode: recovery_file_mode(&metadata), - } - } else if file_type.is_file() { - let maximum = MAX_RECOVERY_METADATA_BYTES.saturating_sub(capture.metadata_bytes); - let mut contents = Vec::new(); - let (file, opened_metadata) = open_recovery_regular_file(&path, &relative, &metadata)?; - file.take(maximum.saturating_add(1) as u64) - .read_to_end(&mut contents) - .map_err(|source| recovery_metadata_io_error(&relative, source))?; - ensure_recovery_regular_file_path_unchanged(&path, &relative, &opened_metadata)?; - if contents.len() > maximum { - return Err(recovery_bound_error(format!( - "metadata bytes exceed {MAX_RECOVERY_METADATA_BYTES}" - ))); - } - capture.reserve_metadata_bytes( - relative - .as_os_str() - .as_encoded_bytes() - .len() - .saturating_add(contents.len()), - )?; - RecoveryMetadataValue::File { - contents, - mode: recovery_file_mode(&opened_metadata), - } - } else if file_type.is_symlink() { - let target = fs::read_link(&path) - .map_err(|source| recovery_metadata_io_error(&relative, source))?; - capture.reserve_metadata_bytes( - relative - .as_os_str() - .as_encoded_bytes() - .len() - .saturating_add(target.as_os_str().as_encoded_bytes().len()), - )?; - RecoveryMetadataValue::Symlink(target) - } else { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because control metadata {} has an unsupported filesystem type", - relative.display() - ), - }); - }; - entries.push(RecoveryMetadataEntry { - path: relative.clone(), - value, - }); - - if file_type.is_dir() { - for child in fs::read_dir(&path) - .map_err(|source| recovery_metadata_io_error(&relative, source))? - { - let child = - child.map_err(|source| recovery_metadata_io_error(&relative, source))?; - capture.reserve_metadata_entry()?; - pending.push((relative.join(child.file_name()), child.path())); - } - } - } - entries.sort_by(|left, right| left.path.cmp(&right.path)); - Ok(entries) -} - -#[cfg(target_os = "linux")] -fn ensure_recovery_path_has_no_symlink_ancestors( - root: &Path, - relative: &Path, - description: &str, - final_must_be_directory: bool, -) -> Result<(), GitError> { - use rustix::fs::{CWD, FileType, Mode, OFlags, openat}; - - let mut directory = openat( - CWD, - root, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(|source| recovery_cleanup_error("open recovery path root", source))?; - let mut checked = PathBuf::new(); - let mut components = relative.components().peekable(); - while let Some(component) = components.next() { - let std::path::Component::Normal(name) = component else { - return Err(recovery_transaction_blocked(format!( - "atomic recovery requires {description} to be beneath the repository" - ))); - }; - checked.push(name); - let name = CString::new(name.as_encoded_bytes()).map_err(|_| { - recovery_transaction_blocked(format!( - "atomic recovery {description} contains an invalid path component" - )) - })?; - let entry = match openat( - &directory, - name.as_c_str(), - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - ) { - Ok(entry) => entry, - Err(rustix::io::Errno::NOENT) => return Ok(()), - Err(source) => { - return Err(recovery_cleanup_error( - "inspect recovery path component", - source, - )); - } - }; - let metadata = rustix::fs::fstat(&entry) - .map_err(|source| recovery_cleanup_error("inspect recovery path component", source))?; - let file_type = FileType::from_raw_mode(metadata.st_mode); - let is_final = components.peek().is_none(); - if file_type != FileType::Directory - && (!is_final || final_must_be_directory || file_type != FileType::RegularFile) - { - return Err(recovery_transaction_blocked(format!( - "atomic recovery rejects {description} with symlinked or non-directory component {}", - checked.display() - ))); - } - if file_type == FileType::Directory { - directory = entry; - } - } - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn ensure_recovery_path_has_no_symlink_ancestors( - root: &Path, - relative: &Path, - description: &str, - final_must_be_directory: bool, -) -> Result<(), GitError> { - let mut path = root.to_owned(); - let mut components = relative.components().peekable(); - while let Some(component) = components.next() { - let std::path::Component::Normal(name) = component else { - return Err(recovery_transaction_blocked(format!( - "atomic recovery requires {description} to be beneath the repository" - ))); - }; - path.push(name); - match fs::symlink_metadata(&path) { - Ok(metadata) - if metadata.file_type().is_dir() - || (components.peek().is_none() - && !final_must_be_directory - && metadata.file_type().is_file()) => {} - Ok(_) => { - return Err(recovery_transaction_blocked(format!( - "atomic recovery rejects {description} with a symlinked or non-directory component" - ))); - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(source) => return Err(recovery_transaction_io("inspect recovery path", source)), - } - } - Ok(()) -} - -fn recovery_metadata_io_error(path: &Path, source: std::io::Error) -> GitError { - GitError::Io { - args: vec![ - "snapshot-recovery-metadata".to_owned(), - path.to_string_lossy().into_owned(), - ], - source, - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Repository { - pub root: PathBuf, - pub branch: BranchState, - pub remotes: Vec, - pub status: WorktreeStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GitOutput { - pub status: ExitStatus, - pub stdout: String, - pub stderr: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HeadTarget { - pub oid: Option, - pub reference: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchInfo { - pub name: String, - pub reference: String, - pub oid: String, - pub upstream: Option, - pub current: bool, - pub kind: BranchKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchTarget { - pub name: String, - pub reference: String, - pub oid: String, - pub kind: BranchKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BranchKind { - Local, - Remote, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RawGitOutput { - stdout: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RawProcessOutput { - args: Vec, - status: ExitStatus, - stdout: Vec, - stderr: Vec, -} - -#[derive(Debug)] -pub enum GitError { - Io { - args: Vec, - source: std::io::Error, - }, - Utf8 { - args: Vec, - stream: OutputStream, - source: FromUtf8Error, - }, - GitFailed { - args: Vec, - status: ExitStatus, - stdout: String, - stderr: String, - }, - Blocked { - message: String, - }, - Parse { - message: String, - }, -} - -impl Display for GitError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Io { args, source } => { - write!(formatter, "failed to run git {}: {source}", args.join(" ")) - } - Self::Utf8 { - args, - stream, - source, - } => write!( - formatter, - "git {} returned non-UTF-8 {stream}: {source}", - args.join(" ") - ), - Self::GitFailed { - args, - status, - stdout, - stderr, - .. - } => { - let detail = if !stderr.trim().is_empty() { - stderr.trim() - } else if !stdout.trim().is_empty() { - stdout.trim() - } else { - "no output" - }; - write!( - formatter, - "git {} failed with status {status}: {detail}", - args.join(" ") - ) - } - Self::Blocked { message } => formatter.write_str(message), - Self::Parse { message } => write!(formatter, "failed to parse git output: {message}"), - } - } -} - -impl Error for GitError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - Self::Utf8 { source, .. } => Some(source), - Self::GitFailed { .. } | Self::Blocked { .. } | Self::Parse { .. } => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputStream { - Stdout, - Stderr, -} - -impl Display for OutputStream { - fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Stdout => formatter.write_str("stdout"), - Self::Stderr => formatter.write_str("stderr"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BranchState { - pub head: Head, - pub upstream: Option, - pub ahead: u32, - pub behind: u32, - pub unborn: bool, -} - -impl Default for BranchState { - fn default() -> Self { - Self { - head: Head::Unborn, - upstream: None, - ahead: 0, - behind: 0, - unborn: false, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Head { - Branch(String), - Detached(String), - Unborn, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RepositoryOperation { - Merge, - Rebase, -} - -impl RepositoryOperation { - fn label(self) -> &'static str { - match self { - Self::Merge => "merge", - Self::Rebase => "rebase", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RecoveryAction { - Continue, - Abort, - Skip, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RecoveryState { - operation: Option, - head: HeadTarget, - index: Vec, - worktree: Vec, - metadata: Vec, - refs: Vec, - execution: RecoveryExecutionState, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RecoveryExecutionState { - config: Vec, - config_files: Vec, - attributes: Vec, - hooks_location: RecoveryHookLocation, - hooks: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecoveryHookLocation { - Repository(PathBuf), - External(PathBuf), -} - -impl RecoveryState { - fn rebase_progressed_from(&self, expected: &Self) -> bool { - self.head != expected.head - || REBASE_PROGRESS_PATHS.iter().any(|path| { - let current = recovery_metadata_file(&self.metadata, path) - .and_then(parse_rebase_progress_marker); - let previous = recovery_metadata_file(&expected.metadata, path) - .and_then(parse_rebase_progress_marker); - matches!((current, previous), (Some(current), Some(previous)) if current > previous) - }) - } -} - -impl RecoveryExecutionState { - fn hooks_are_repository_local(&self) -> bool { - matches!(self.hooks_location, RecoveryHookLocation::Repository(_)) - && !self - .hooks - .iter() - .any(|entry| matches!(entry.value, RecoveryMetadataValue::Symlink(_))) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RecoveryWorktreeEntry { - path: PathBuf, - value: RecoveryWorktreeValue, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecoveryWorktreeValue { - Missing, - File { - size: u64, - mode: u32, - identity: (u64, u64), - digest: [u8; 32], - }, - Directory { - mode: u32, - identity: (u64, u64), - }, - Symlink(PathBuf), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RecoveryMetadataEntry { - path: PathBuf, - value: RecoveryMetadataValue, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecoveryMetadataValue { - Missing, - Directory { mode: u32 }, - File { contents: Vec, mode: u32 }, - Symlink(PathBuf), -} - -fn parse_rebase_progress_marker(contents: &[u8]) -> Option { - std::str::from_utf8(contents).ok()?.trim().parse().ok() -} - -impl RecoveryAction { - fn label(self) -> &'static str { - match self { - Self::Continue => "continue", - Self::Abort => "abort", - Self::Skip => "skip", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Remote { - pub name: String, - pub fetch_url: Option, - pub push_url: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeStatus { - pub branch: BranchState, - pub entries: Vec, - pub operation: Option, -} - -impl WorktreeStatus { - pub fn is_clean(&self) -> bool { - self.entries.is_empty() - } - - pub fn staged_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| { - matches!( - entry.entry_type, - StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied - ) && entry.index != ChangeKind::Unmodified - }) - .collect() - } - - pub fn unstaged_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| { - matches!( - entry.entry_type, - StatusEntryType::Ordinary | StatusEntryType::Renamed | StatusEntryType::Copied - ) && entry.worktree != ChangeKind::Unmodified - }) - .collect() - } - - pub fn untracked_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| entry.entry_type == StatusEntryType::Untracked) - .collect() - } - - pub fn conflicted_files(&self) -> Vec<&StatusEntry> { - self.entries - .iter() - .filter(|entry| entry.entry_type == StatusEntryType::Conflict) - .collect() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StatusEntry { - pub path: PathBuf, - pub original_path: Option, - pub index: ChangeKind, - pub worktree: ChangeKind, - pub entry_type: StatusEntryType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StatusEntryType { - Ordinary, - Renamed, - /// Porcelain v2 reserves the `2` record family for renames and copies. Git - /// status does not normally emit copies, but the parser keeps the model - /// explicit for forward-compatible callers. - Copied, - Untracked, - Ignored, - Conflict, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeKind { - Unmodified, - Modified, - Added, - Deleted, - Renamed, - Copied, - Unmerged, - Untracked, - Ignored, - Unknown(char), -} - -#[cfg(test)] -fn parse_status(input: &str) -> Result { - parse_status_bytes(input.as_bytes()) -} - -fn parse_status_bytes(input: &[u8]) -> Result { - let mut branch = BranchState::default(); - let mut oid = None; - let mut entries = Vec::new(); - let nul_delimited = input.contains(&0); - let mut records: Box + '_> = if nul_delimited { - Box::new( - input - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()), - ) - } else { - Box::new( - input - .split(|byte| *byte == b'\n') - .filter(|record| !record.is_empty()), - ) - }; - - while let Some(line) = records.next() { - if let Some(value) = strip_bytes_prefix(line, b"# branch.oid ") { - let value = parse_utf8(value, "branch oid")?; - oid = Some(value.to_owned()); - if value == "(initial)" { - branch.unborn = true; - } - continue; - } - - if let Some(value) = strip_bytes_prefix(line, b"# branch.head ") { - let value = parse_utf8(value, "branch head")?; - branch.head = if value == "(detached)" { - Head::Detached(oid.clone().unwrap_or_default()) - } else { - Head::Branch(value.to_owned()) - }; - continue; - } - - if let Some(value) = strip_bytes_prefix(line, b"# branch.upstream ") { - let value = parse_utf8(value, "branch upstream")?; - branch.upstream = Some(value.to_owned()); - continue; - } - - if let Some(value) = strip_bytes_prefix(line, b"# branch.ab ") { - let value = parse_utf8(value, "branch ahead/behind")?; - let (ahead, behind) = parse_ahead_behind(value)?; - branch.ahead = ahead; - branch.behind = behind; - continue; - } - - if let Some(path) = strip_bytes_prefix(line, b"? ") { - entries.push(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmodified, - worktree: ChangeKind::Untracked, - entry_type: StatusEntryType::Untracked, - }); - continue; - } - - if let Some(path) = strip_bytes_prefix(line, b"! ") { - entries.push(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmodified, - worktree: ChangeKind::Ignored, - entry_type: StatusEntryType::Ignored, - }); - continue; - } - - if line.starts_with(b"1 ") { - entries.push(parse_ordinary_entry(line)?); - continue; - } - - if line.starts_with(b"2 ") { - let original_path = if nul_delimited { - Some( - records - .next() - .ok_or_else(|| parse_error("renamed entry missing original path"))?, - ) - } else { - None - }; - entries.push(parse_renamed_entry(line, original_path)?); - continue; - } - - if line.starts_with(b"u ") { - entries.push(parse_conflict_entry(line)?); - continue; - } - - if !line.starts_with(b"# ") { - return Err(parse_error(&format!( - "unknown porcelain v2 record: {}", - String::from_utf8_lossy(line) - ))); - } - } - - Ok(WorktreeStatus { - branch, - entries, - operation: None, - }) -} - -fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> { - let mut parts = value.split_whitespace(); - let ahead = parts - .next() - .ok_or_else(|| parse_error("missing ahead count"))? - .strip_prefix('+') - .ok_or_else(|| parse_error("ahead count must start with +"))? - .parse::() - .map_err(|_| parse_error("ahead count is not a number"))?; - let behind = parts - .next() - .ok_or_else(|| parse_error("missing behind count"))? - .strip_prefix('-') - .ok_or_else(|| parse_error("behind count must start with -"))? - .parse::() - .map_err(|_| parse_error("behind count is not a number"))?; - Ok((ahead, behind)) -} - -fn parse_ordinary_entry(line: &[u8]) -> Result { - let parts = splitn_bytes(line, b' ', 9); - let xy = parts - .get(1) - .copied() - .ok_or_else(|| parse_error("ordinary entry missing status"))?; - let path = parts - .get(8) - .copied() - .ok_or_else(|| parse_error("ordinary entry missing path"))?; - let (index, worktree) = parse_xy(xy)?; - - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index, - worktree, - entry_type: StatusEntryType::Ordinary, - }) -} - -fn parse_renamed_entry(line: &[u8], original_path: Option<&[u8]>) -> Result { - let parts = splitn_bytes(line, b' ', 10); - let xy = parts - .get(1) - .copied() - .ok_or_else(|| parse_error("renamed entry missing status"))?; - let path = parts - .get(9) - .copied() - .ok_or_else(|| parse_error("renamed entry missing path"))?; - let (path, original_path) = match original_path { - Some(original_path) => (path, original_path), - None => split_once_byte(path, b'\t') - .ok_or_else(|| parse_error("renamed entry missing original path"))?, - }; - let (index, worktree) = parse_xy(xy)?; - - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: Some(path_from_bytes(original_path)), - index, - worktree, - entry_type: if index == ChangeKind::Copied || worktree == ChangeKind::Copied { - StatusEntryType::Copied - } else { - StatusEntryType::Renamed - }, - }) -} - -fn parse_conflict_entry(line: &[u8]) -> Result { - let parts = splitn_bytes(line, b' ', 11); - let path = parts - .get(10) - .copied() - .ok_or_else(|| parse_error("conflict entry missing path"))?; - - Ok(StatusEntry { - path: path_from_bytes(path), - original_path: None, - index: ChangeKind::Unmerged, - worktree: ChangeKind::Unmerged, - entry_type: StatusEntryType::Conflict, - }) -} - -fn parse_xy(value: &[u8]) -> Result<(ChangeKind, ChangeKind), GitError> { - let index = value - .first() - .copied() - .ok_or_else(|| parse_error("missing index status"))?; - let worktree = value - .get(1) - .copied() - .ok_or_else(|| parse_error("missing worktree status"))?; - Ok((change_kind(index), change_kind(worktree))) -} - -fn change_kind(value: u8) -> ChangeKind { - match value { - b'.' => ChangeKind::Unmodified, - b'M' => ChangeKind::Modified, - b'A' => ChangeKind::Added, - b'D' => ChangeKind::Deleted, - b'R' => ChangeKind::Renamed, - b'C' => ChangeKind::Copied, - b'U' => ChangeKind::Unmerged, - b'?' => ChangeKind::Untracked, - b'!' => ChangeKind::Ignored, - other => ChangeKind::Unknown(char::from(other)), - } -} - -fn parse_remotes(input: &str) -> Vec { - let mut remotes = BTreeMap::::new(); - - for line in input.lines() { - let Some((name, rest)) = line.split_once('\t') else { - continue; - }; - let Some((url, kind)) = rest.rsplit_once(' ') else { - continue; - }; - - let remote = remotes.entry(name.to_owned()).or_insert_with(|| Remote { - name: name.to_owned(), - fetch_url: None, - push_url: None, - }); - - match kind { - "(fetch)" => remote.fetch_url = Some(url.to_owned()), - "(push)" => remote.push_url = Some(url.to_owned()), - _ => {} - } - } - - remotes.into_values().collect() -} - -fn parse_branches(input: &str) -> Result, GitError> { - let mut branches = Vec::new(); - for line in input.lines().filter(|line| !line.trim().is_empty()) { - let branch = parse_branch_line(line)?; - if branch.kind == BranchKind::Remote && branch.name.ends_with("/HEAD") { - continue; - } - branches.push(branch); - } - Ok(branches) -} - -fn parse_branch_line(line: &str) -> Result { - let fields = line.split('\0').collect::>(); - let [reference, oid, upstream, head] = fields.as_slice() else { - return Err(GitError::Parse { - message: "git branch list output has unexpected fields".to_owned(), - }); - }; - let (kind, name) = if let Some(name) = reference.strip_prefix("refs/heads/") { - (BranchKind::Local, name) - } else if let Some(name) = reference.strip_prefix("refs/remotes/") { - (BranchKind::Remote, name) - } else { - return Err(GitError::Parse { - message: format!("unsupported branch reference: {reference}"), - }); - }; - Ok(BranchInfo { - name: name.to_owned(), - reference: (*reference).to_owned(), - oid: (*oid).to_owned(), - upstream: (!upstream.is_empty()).then(|| (*upstream).to_owned()), - current: *head == "*", - kind, - }) -} - -fn local_name_for_remote_branch(name: &str) -> Option<&str> { - name.split_once('/') - .map(|(_remote, branch)| branch) - .filter(|branch| !branch.is_empty()) -} - -fn strip_byte_line_ending(value: &[u8]) -> &[u8] { - value.strip_suffix(b"\n").unwrap_or(value) -} - -fn strip_bytes_prefix<'a>(value: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> { - value.strip_prefix(prefix) -} - -fn parse_utf8<'a>(value: &'a [u8], description: &str) -> Result<&'a str, GitError> { - std::str::from_utf8(value).map_err(|_| parse_error(&format!("{description} is not UTF-8"))) -} - -fn splitn_bytes(value: &[u8], delimiter: u8, count: usize) -> Vec<&[u8]> { - value.splitn(count, |byte| *byte == delimiter).collect() -} - -fn split_once_byte(value: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { - let index = value.iter().position(|byte| *byte == delimiter)?; - Some((&value[..index], &value[index + 1..])) -} - -fn shell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -#[cfg(unix)] -fn path_from_bytes(value: &[u8]) -> PathBuf { - PathBuf::from(OsString::from_vec(value.to_vec())) -} - -#[cfg(not(unix))] -fn path_from_bytes(value: &[u8]) -> PathBuf { - PathBuf::from(String::from_utf8_lossy(value).into_owned()) -} - -fn parse_error(message: &str) -> GitError { - GitError::Parse { - message: message.to_owned(), - } -} - -fn remote_tracking_ref(remote: &str, branch: &str) -> String { - format!("refs/remotes/{remote}/{branch}") -} - -fn force_with_lease_arg(branch: &str, expected_remote_oid: Option<&str>) -> String { - format!( - "--force-with-lease=refs/heads/{branch}:{}", - expected_remote_oid.unwrap_or("") - ) -} - -fn combine_outputs(first: GitOutput, second: GitOutput) -> GitOutput { - let stdout = [first.stdout.trim(), second.stdout.trim()] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - let stderr = [first.stderr.trim(), second.stderr.trim()] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - GitOutput { - status: second.status, - stdout, - stderr, - } -} - -#[cfg(unix)] -fn hook_is_enabled(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - fs::metadata(path) - .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} - -#[cfg(not(unix))] -fn hook_is_enabled(path: &Path) -> bool { - fs::metadata(path) - .map(|metadata| metadata.is_file()) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::Path; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[cfg(unix)] - use std::os::unix::ffi::{OsStrExt, OsStringExt}; - - static NEXT_REPO_ID: AtomicUsize = AtomicUsize::new(0); - - fn recovery_capabilities_or_verify_fail_closed( - git: &Git, - operation: RepositoryOperation, - action: RecoveryAction, - expected: &RecoveryState, - ) -> Result> { - match git.ensure_recovery_supported() { - Ok(()) => Ok(true), - Err(GitError::Blocked { message }) - if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE) => - { - if env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { - return Err(format!( - "production recovery capabilities are required for this test: {message}" - ) - .into()); - } - let root = git.repo_root()?.canonicalize()?; - let before = RecoveryGeneration::capture(&root)?; - let Err(error) = git.recover_exact(operation, action, expected) else { - return Err("recovery ran without its required platform capabilities".into()); - }; - assert!( - matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), - "{error}" - ); - assert_eq!(RecoveryGeneration::capture(&root)?, before); - Ok(false) - } - Err(error) => Err(error.into()), - } - } - - #[test] - fn parses_clean_status() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - )?; - - assert!(status.is_clean()); - assert_eq!(status.branch.head, Head::Branch("main".to_owned())); - assert_eq!(status.branch.upstream, Some("origin/main".to_owned())); - assert_eq!(status.branch.ahead, 0); - assert_eq!(status.branch.behind, 0); - assert_eq!(status.operation, None); - Ok(()) - } - - #[test] - fn parses_dirty_unstaged_file() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n1 .M N... 100644 100644 100644 abc abc README.md\n", - )?; - - assert_eq!(status.unstaged_files().len(), 1); - assert_eq!(status.entries[0].path, PathBuf::from("README.md")); - assert_eq!(status.entries[0].worktree, ChangeKind::Modified); - Ok(()) - } - - #[test] - fn parses_staged_file() -> Result<(), Box> { - let status = parse_status( - "# branch.oid 1234567\n# branch.head main\n1 A. N... 000000 100644 100644 zero abc src/main.rs\n", - )?; - - assert_eq!(status.staged_files().len(), 1); - assert_eq!(status.entries[0].index, ChangeKind::Added); - Ok(()) - } - - #[test] - fn parses_untracked_file() -> Result<(), Box> { - let status = parse_status("# branch.head main\n? notes.txt\n")?; - - assert_eq!(status.untracked_files().len(), 1); - assert_eq!(status.entries[0].entry_type, StatusEntryType::Untracked); - Ok(()) - } - - #[test] - fn parses_renamed_file() -> Result<(), Box> { - let status = parse_status( - "# branch.head main\n2 R. N... 100644 100644 100644 abc def R100 new.txt\told.txt\n", - )?; - - assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); - assert_eq!(status.entries[0].path, PathBuf::from("new.txt")); - assert_eq!( - status.entries[0].original_path, - Some(PathBuf::from("old.txt")) - ); - Ok(()) - } - - #[test] - fn parses_conflicted_file() -> Result<(), Box> { - let status = parse_status( - "# branch.head main\nu UU N... 100644 100644 100644 100644 one two three conflict.txt\n", - )?; - - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!(status.staged_files().len(), 0); - assert_eq!(status.entries[0].entry_type, StatusEntryType::Conflict); - Ok(()) - } - - #[test] - fn parses_detached_head() -> Result<(), Box> { - let status = parse_status("# branch.oid abc123\n# branch.head (detached)\n")?; - - assert_eq!(status.branch.head, Head::Detached("abc123".to_owned())); - Ok(()) - } - - #[test] - fn parses_unborn_branch() -> Result<(), Box> { - let status = parse_status("# branch.oid (initial)\n# branch.head main\n")?; - - assert_eq!(status.branch.head, Head::Branch("main".to_owned())); - assert!(status.branch.unborn); - Ok(()) - } - - #[test] - fn parses_nul_delimited_raw_paths() -> Result<(), Box> { - let status = parse_status("# branch.head main\0? café.txt\0? tab\tname.txt\0")?; - - assert_eq!(status.untracked_files().len(), 2); - assert_eq!(status.entries[0].path, PathBuf::from("café.txt")); - assert_eq!(status.entries[1].path, PathBuf::from("tab\tname.txt")); - Ok(()) - } - - #[test] - fn parses_nul_delimited_rename() -> Result<(), Box> { - let status = parse_status(concat!( - "# branch.head main\0", - "2 R. N... 100644 100644 100644 abc def R100 new\tname.txt\0", - "old name.txt\0" - ))?; - - assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); - assert_eq!(status.entries[0].path, PathBuf::from("new\tname.txt")); - assert_eq!( - status.entries[0].original_path, - Some(PathBuf::from("old name.txt")) - ); - Ok(()) - } - - #[test] - fn parses_copied_file() -> Result<(), Box> { - let status = parse_status(concat!( - "# branch.head main\0", - "2 C. N... 100644 100644 100644 abc def C100 copy.txt\0", - "source.txt\0" - ))?; - - assert_eq!(status.entries[0].entry_type, StatusEntryType::Copied); - assert_eq!(status.staged_files().len(), 1); - Ok(()) - } - - #[test] - fn parses_remotes() { - let remotes = parse_remotes( - "origin\thttps://github.com/cosentinode/bitbygit.git (fetch)\norigin\tgit@github.com:cosentinode/bitbygit.git (push)\n", - ); - - assert_eq!(remotes.len(), 1); - assert_eq!(remotes[0].name, "origin"); - assert_eq!( - remotes[0].fetch_url, - Some("https://github.com/cosentinode/bitbygit.git".to_owned()) - ); - assert_eq!( - remotes[0].push_url, - Some("git@github.com:cosentinode/bitbygit.git".to_owned()) - ); - } - - #[test] - fn parses_branches_and_skips_remote_head() -> Result<(), Box> { - let branches = parse_branches( - "refs/heads/main\x001111111111111111111111111111111111111111\x00origin/main\x00*\nrefs/remotes/origin/main\x002222222222222222222222222222222222222222\x00\x00\nrefs/remotes/origin/HEAD\x002222222222222222222222222222222222222222\x00\x00\n", - )?; - - assert_eq!(branches.len(), 2); - assert_eq!(branches[0].name, "main"); - assert_eq!(branches[0].kind, BranchKind::Local); - assert!(branches[0].current); - assert_eq!(branches[0].upstream.as_deref(), Some("origin/main")); - assert_eq!(branches[1].name, "origin/main"); - assert_eq!(branches[1].kind, BranchKind::Remote); - Ok(()) - } - - #[test] - fn branch_target_blocks_ambiguous_local_and_remote_names() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["branch", "origin/main"])?; - repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; - let git = Git::new(repo.path()); - - let result = git.branch_target("origin/main"); - - let Err(error) = result else { - return Err("expected ambiguous branch guardrail".into()); - }; - assert!(error.to_string().contains("ambiguous")); - Ok(()) - } - - #[test] - fn reads_repository_state_from_temp_repo() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.write("README.md", "changed\n")?; - repo.write("staged.txt", "staged\n")?; - repo.run(["add", "staged.txt"])?; - repo.write("untracked.txt", "untracked\n")?; - - let git = Git::new(repo.path()); - let state = git.repository()?; - - assert_eq!(state.root, repo.path()); - assert_eq!(state.branch.head, Head::Branch("main".to_owned())); - assert_eq!(state.status.staged_files().len(), 1); - assert_eq!(state.status.unstaged_files().len(), 1); - assert_eq!(state.status.untracked_files().len(), 1); - Ok(()) - } - - #[test] - fn branch_workflows_create_checkout_merge_and_rebase() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - let git = Git::new(repo.path()); - - git.create_branch("feature", None, &git.head_target()?)?; - repo.write("feature.txt", "feature\n")?; - repo.run(["add", "feature.txt"])?; - repo.run(["commit", "-m", "feature"])?; - let feature = git.branch_target("feature")?.ok_or("missing feature")?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.checkout_branch(&main, &git.head_target()?)?; - git.merge_ff_only(&feature, &git.head_target()?)?; - assert!(repo.path().join("feature.txt").exists()); - - git.create_branch("topic", None, &git.head_target()?)?; - repo.write("topic.txt", "topic\n")?; - repo.run(["add", "topic.txt"])?; - repo.run(["commit", "-m", "topic"])?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.checkout_branch(&main, &git.head_target()?)?; - repo.write("base.txt", "base\n")?; - repo.run(["add", "base.txt"])?; - repo.run(["commit", "-m", "base"])?; - let topic = git.branch_target("topic")?.ok_or("missing topic")?; - git.checkout_branch(&topic, &git.head_target()?)?; - let main = git.branch_target("main")?.ok_or("missing main")?; - git.rebase_onto(&main, &git.head_target()?)?; - - repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; - Ok(()) - } - - #[test] - fn branch_workflows_block_dirty_tree() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.write("README.md", "dirty\n")?; - let git = Git::new(repo.path()); - - let result = git.create_branch("feature", None, &git.head_target()?); - - let Err(error) = result else { - return Err("expected dirty tree guardrail".into()); - }; - assert!(error.to_string().contains("working tree is not clean")); - Ok(()) - } - - #[test] - fn remote_checkout_blocks_when_local_branch_exists() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; - let git = Git::new(repo.path()); - let remote = git.branch_target("origin/main")?.ok_or("missing remote")?; - - let result = git.checkout_branch(&remote, &git.head_target()?); - - let Err(error) = result else { - return Err("expected remote checkout guardrail".into()); - }; - assert!( - error - .to_string() - .contains("local branch main already exists") - ); - Ok(()) - } - - #[test] - fn branch_workflows_block_in_progress_rebase() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("README.md", "initial\n")?; - repo.run(["add", "README.md"])?; - repo.run(["commit", "-m", "initial"])?; - repo.run(["switch", "-c", "topic"])?; - repo.write("topic.txt", "topic\n")?; - repo.run(["add", "topic.txt"])?; - repo.run(["commit", "-m", "topic"])?; - repo.run(["switch", "main"])?; - repo.write("base.txt", "base\n")?; - repo.run(["add", "base.txt"])?; - repo.run(["commit", "-m", "base"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; - let git = Git::new(repo.path()); - - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - - let result = git.create_branch("new-topic", None, &git.head_target()?); - - let Err(error) = result else { - return Err("expected in-progress rebase guardrail".into()); - }; - assert!( - error - .to_string() - .contains("rebase operation is in progress") - ); - Ok(()) - } - - #[test] - fn detects_conflicts_in_temp_repo() -> Result<(), Box> { - let repo = TempRepo::new()?; - repo.run(["init", "-b", "main"])?; - repo.run(["config", "user.email", "bitbygit@example.invalid"])?; - repo.run(["config", "user.name", "bitbygit test"])?; - repo.write("conflict.txt", "base\n")?; - repo.run(["add", "conflict.txt"])?; - repo.run(["commit", "-m", "base"])?; - repo.run(["checkout", "-b", "other"])?; - repo.write("conflict.txt", "other\n")?; - repo.run(["commit", "-am", "other"])?; - repo.run(["checkout", "main"])?; - repo.write("conflict.txt", "main\n")?; - repo.run(["commit", "-am", "main"])?; - repo.run_allow_failure(["merge", "other"])?; - - let status = Git::new(repo.path()).status()?; - - assert_eq!(status.operation, Some(RepositoryOperation::Merge)); - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("conflict.txt") - ); - - repo.run(["add", "conflict.txt"])?; - let status = Git::new(repo.path()).status()?; - - assert_eq!(status.operation, Some(RepositoryOperation::Merge)); - assert!(status.conflicted_files().is_empty()); - Ok(()) - } - - #[test] - fn merge_continue_requires_resolution_and_finishes_without_an_editor() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - repo.run(["config", "core.editor", "false"])?; - let git = Git::new(repo.path()); - - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected unresolved merge to block continue".into()); - }; - assert!(error.to_string().contains("unresolved conflicts")); - - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; - - assert_eq!(git.status()?.operation, None); - repo.run(["rev-parse", "--verify", "HEAD^2"])?; - Ok(()) - } - - #[test] - fn merge_abort_clears_operation_and_restores_head() -> Result<(), Box> { - let (repo, original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Merge, RecoveryAction::Abort)?; - - assert_eq!(git.status()?.operation, None); - assert_eq!( - repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "main\n" - ); - Ok(()) - } - - #[test] - fn rebase_continue_reports_the_next_conflict_then_finishes() -> Result<(), Box> { - let repo = prepare_two_conflict_rebase()?; - repo.run(["config", "core.editor", "false"])?; - let git = Git::new(repo.path()); - - repo.write("first.txt", "topic first\n")?; - repo.run(["add", "first.txt"])?; - let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { - return Err("expected rebase to stop at the next conflict".into()); - }; - let GitError::GitFailed { - args, - stdout, - stderr, - .. - } = &error - else { - return Err(format!("expected git failure for next conflict, got {error}").into()); - }; - assert_eq!(args, &["rebase".to_owned(), "--continue".to_owned()]); - assert!(format!("{stdout}\n{stderr}").contains("second.txt")); - let status = git.status()?; - assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); - assert_eq!(status.conflicted_files().len(), 1); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("second.txt") - ); - - repo.write("second.txt", "topic second\n")?; - repo.run(["add", "second.txt"])?; - git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; - - assert_eq!(git.status()?.operation, None); - repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; - Ok(()) - } - - #[test] - fn rebase_abort_clears_operation_and_restores_head() -> Result<(), Box> { - let (repo, original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - - assert_eq!(git.status()?.operation, None); - assert_eq!( - repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "topic\n" - ); - Ok(()) - } - - #[test] - fn rebase_skip_drops_the_conflicting_commit() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - - git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; - - assert_eq!(git.status()?.operation, None); - assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "main\n" - ); - assert_eq!( - repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - repo.git_stdout(["rev-parse", "main"])?.trim() - ); - Ok(()) - } - - #[test] - fn rebase_merge_step_recovery_preserves_rebase_identity() -> Result<(), Box> { - let (abort_repo, original_head) = prepare_rebase_merge_conflict()?; - let abort_git = Git::new(abort_repo.path()); - - let Err(error) = abort_git.recover(RepositoryOperation::Merge, RecoveryAction::Abort) - else { - return Err("expected nested merge recovery to be rejected".into()); - }; - assert!(error.to_string().contains("rebase operation is active")); - assert!(abort_git.git_path("rebase-merge")?.exists()); - assert!(abort_git.git_path("MERGE_HEAD")?.exists()); - - abort_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - assert_eq!(abort_git.status()?.operation, None); - assert_eq!( - abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); - - let (continue_repo, _original_head) = prepare_rebase_merge_conflict()?; - let continue_git = Git::new(continue_repo.path()); - let Err(error) = - continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) - else { - return Err("expected unresolved nested merge to block continue".into()); - }; - assert!(error.to_string().contains("unresolved conflicts")); - continue_repo.write("conflict.txt", "resolved again\n")?; - continue_repo.run(["add", "conflict.txt"])?; - continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; - assert_eq!(continue_git.status()?.operation, None); - - let (skip_repo, _original_head) = prepare_rebase_merge_conflict()?; - let skip_git = Git::new(skip_repo.path()); - skip_git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; - assert_eq!(skip_git.status()?.operation, None); - Ok(()) - } - - #[test] - fn recovery_rejects_merge_skip_absent_state_and_mismatched_state() -> Result<(), Box> - { - let clean = initialized_repo()?; - let clean_git = Git::new(clean.path()); - for (operation, action) in [ - (RepositoryOperation::Merge, RecoveryAction::Continue), - (RepositoryOperation::Merge, RecoveryAction::Abort), - (RepositoryOperation::Rebase, RecoveryAction::Continue), - (RepositoryOperation::Rebase, RecoveryAction::Abort), - (RepositoryOperation::Rebase, RecoveryAction::Skip), - ] { - let Err(error) = clean_git.recover(operation, action) else { - return Err( - format!("expected {operation:?} {action:?} to require active state").into(), - ); - }; - assert!(error.to_string().contains("no")); - assert!(error.to_string().contains("operation is active")); - } - - let (merge_repo, _original_head) = prepare_merge_conflict()?; - let merge_git = Git::new(merge_repo.path()); - let Err(error) = merge_git.recover(RepositoryOperation::Merge, RecoveryAction::Skip) else { - return Err("expected merge skip to be rejected".into()); - }; - assert!(error.to_string().contains("does not support")); - assert_eq!( - merge_git.status()?.operation, - Some(RepositoryOperation::Merge) - ); - - let Err(error) = merge_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort) - else { - return Err("expected mismatched rebase recovery to be rejected".into()); - }; - assert!(error.to_string().contains("merge operation is active")); - assert_eq!( - merge_git.status()?.operation, - Some(RepositoryOperation::Merge) - ); - Ok(()) - } - - #[test] - fn exact_recovery_executes_every_supported_action() -> Result<(), Box> { - let (merge_continue_repo, _original_head) = prepare_merge_conflict()?; - merge_continue_repo.write("conflict.txt", "resolved\n")?; - merge_continue_repo.run(["add", "conflict.txt"])?; - let git = Git::new(merge_continue_repo.path()); - let state = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &state, - )? { - return Ok(()); - } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; - assert_eq!(git.status()?.operation, None); - - let (merge_abort_repo, original_head) = prepare_merge_conflict()?; - let git = Git::new(merge_abort_repo.path()); - let state = git.recovery_state()?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &state)?; - assert_eq!(git.status()?.operation, None); - assert_eq!( - merge_abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); - - let (rebase_continue_repo, _original_head) = prepare_rebase_conflict()?; - rebase_continue_repo.write("conflict.txt", "resolved\n")?; - rebase_continue_repo.run(["add", "conflict.txt"])?; - let git = Git::new(rebase_continue_repo.path()); - let state = git.recovery_state()?; - git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Continue, - &state, - )?; - assert_eq!(git.status()?.operation, None); - - let (rebase_abort_repo, original_head) = prepare_rebase_conflict()?; - let git = Git::new(rebase_abort_repo.path()); - let state = git.recovery_state()?; - git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Abort, &state)?; - assert_eq!(git.status()?.operation, None); - assert_eq!( - rebase_abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), - original_head - ); - - let (rebase_skip_repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(rebase_skip_repo.path()); - let state = git.recovery_state()?; - git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &state)?; - assert_eq!(git.status()?.operation, None); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn exact_recovery_supports_hard_linked_objects_from_local_clone() -> Result<(), Box> - { - use std::os::unix::fs::MetadataExt; - - let source = initialized_repo()?; - let clone = TempRepo::new()?; - let output = Command::new("git") - .args(["clone", "--local", "--"]) - .arg(source.path()) - .arg(clone.path()) - .output()?; - if !output.status.success() { - return Err(format!( - "local git clone failed: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - clone.run(["config", "user.email", "bitbygit@example.invalid"])?; - clone.run(["config", "user.name", "bitbygit test"])?; - - let oid = clone.git_stdout(["rev-parse", "HEAD"])?; - let oid = oid.trim(); - let object = PathBuf::from(".git/objects") - .join(&oid[..2]) - .join(&oid[2..]); - assert!(fs::metadata(clone.path().join(&object))?.nlink() > 1); - - let root = clone.path().canonicalize()?; - let baseline = RecoveryGeneration::capture(&root)?; - let root_identity = recovery_path_identity(&root)?; - let parent = root.parent().ok_or("local clone has no parent")?; - let (candidate, _) = create_recovery_candidate(parent, &root)?; - let candidate_identity = recovery_path_identity(&candidate)?; - let mut transaction = RecoveryTransaction { - root: root.clone(), - backup_pointer: recovery_backup_pointer_path(&candidate)?, - candidate, - baseline, - root_identity, - candidate_identity, - keep_candidate: false, - }; - transaction.copy_repository()?; - assert_eq!( - RecoveryGeneration::capture_isolated(&transaction.candidate)?, - transaction.baseline - ); - assert_eq!( - fs::metadata(transaction.candidate.join(&object))?.nlink(), - 1 - ); - drop(transaction); - - clone.write("conflict.txt", "base\n")?; - clone.run(["add", "conflict.txt"])?; - clone.run(["commit", "-m", "base"])?; - clone.run(["switch", "-c", "other"])?; - clone.write("conflict.txt", "other\n")?; - clone.run(["commit", "-am", "other"])?; - clone.run(["switch", "main"])?; - clone.write("conflict.txt", "main\n")?; - clone.run(["commit", "-am", "main"])?; - clone.run_allow_failure(["merge", "other"])?; - let git = Git::new(clone.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - - assert_eq!(git.status()?.operation, None); - assert_eq!(fs::metadata(clone.path().join(object))?.nlink(), 1); - Ok(()) - } - - #[test] - fn exact_rebase_promotes_and_reports_subsequent_conflicts() -> Result<(), Box> { - let continue_repo = prepare_two_conflict_rebase()?; - continue_repo.write("first.txt", "topic first\n")?; - continue_repo.run(["add", "first.txt"])?; - let continue_git = Git::new(continue_repo.path()); - let state = continue_git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &continue_git, - RepositoryOperation::Rebase, - RecoveryAction::Continue, - &state, - )? { - return Ok(()); - } - - let Err(error) = continue_git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Continue, - &state, - ) else { - return Err("expected exact continue to stop at the next conflict".into()); - }; - let GitError::GitFailed { stdout, stderr, .. } = error else { - return Err("expected the next conflict to remain a Git failure".into()); - }; - assert!(format!("{stdout}\n{stderr}").contains("second.txt")); - assert!(stderr.contains("previous repository generation retained at")); - let status = continue_git.status()?; - assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("second.txt") - ); - let first_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? - .ok_or("missing retained recovery backup")?; - assert!(first_backup.is_dir()); - let archived_backup = continue_repo.path().with_extension("first-recovery-backup"); - fs::rename(&first_backup, &archived_backup)?; - - continue_repo.write("second.txt", "topic second\n")?; - continue_repo.run(["add", "second.txt"])?; - let state = continue_git.recovery_state()?; - let output = continue_git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Continue, - &state, - )?; - assert_eq!(continue_git.status()?.operation, None); - assert!( - output - .stderr - .contains("previous repository generation retained at") - ); - let second_backup = recovery_backup_from_pointer(&continue_repo.path().canonicalize()?)? - .ok_or("missing replacement recovery backup")?; - assert_ne!(second_backup, first_backup); - assert!(archived_backup.is_dir()); - assert!(second_backup.is_dir()); - fs::remove_dir_all(&archived_backup)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&first_backup)); - - let skip_repo = prepare_two_conflict_rebase()?; - let skip_git = Git::new(skip_repo.path()); - let state = skip_git.recovery_state()?; - let Err(error) = - skip_git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &state) - else { - return Err("expected exact skip to stop at the next conflict".into()); - }; - let GitError::GitFailed { stdout, stderr, .. } = error else { - return Err("expected the conflict after skip to remain a Git failure".into()); - }; - assert!(format!("{stdout}\n{stderr}").contains("second.txt")); - assert!(stderr.contains("previous repository generation retained at")); - let status = skip_git.status()?; - assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); - assert_eq!( - status.conflicted_files()[0].path, - PathBuf::from("second.txt") - ); - Ok(()) - } - - #[test] - fn failed_rebase_skip_without_progress_does_not_promote() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - let lock = git.git_path("index.lock")?; - fs::write(&lock, "block skip before it advances\n")?; - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Rebase, - RecoveryAction::Skip, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let before = RecoveryGeneration::capture(&root)?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Rebase, RecoveryAction::Skip, &expected) - else { - return Err("expected locked rebase skip to fail".into()); - }; - - assert!(matches!(error, GitError::GitFailed { .. }), "{error}"); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(RecoveryGeneration::capture(&root)?, before); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - fs::remove_file(lock)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_copy_bounds_concurrent_tree_growth() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let baseline = RecoveryGeneration::capture(&root)?; - RECOVERY_COPY_LIMIT_OVERRIDES - .lock() - .map_err(|_| "recovery copy limit lock poisoned")? - .push(( - root.clone(), - ( - baseline.entries.len() + 1, - MAX_RECOVERY_GENERATION_PATH_BYTES, - MAX_RECOVERY_GENERATION_FILE_BYTES, - ), - )); - let growth_root = root.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write(growth_root.join("copy-growth-a"), "a\n"); - let _ = fs::write(growth_root.join("copy-growth-b"), "b\n"); - }); - RECOVERY_COPY_HOOKS - .lock() - .map_err(|_| "recovery copy hook lock poisoned")? - .push((root.clone(), hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected copy-time tree growth to exceed traversal bounds".into()); - }; - - assert!( - error.to_string().contains("repository copy exceeds"), - "{error}" - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - assert_eq!(fs::read_to_string(root.join("copy-growth-a"))?, "a\n"); - assert_eq!(fs::read_to_string(root.join("copy-growth-b"))?, "b\n"); - fs::remove_file(root.join("copy-growth-a"))?; - fs::remove_file(root.join("copy-growth-b"))?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_copy_bounds_diagnostics_without_promoting() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let script = repo.path().with_extension("noisy-copy"); - fs::write( - &script, - "#!/bin/sh\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\n", - )?; - let mut permissions = fs::metadata(&script)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions)?; - RECOVERY_COPY_PROGRAMS - .lock() - .map_err(|_| "recovery copy program lock poisoned")? - .push((root, script.clone().into_os_string())); - - let started = std::time::Instant::now(); - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected noisy repository copy to exceed output bounds".into()); - }; - - assert!( - error - .to_string() - .contains("repository copy output bytes exceed") - ); - assert!(started.elapsed() < std::time::Duration::from_secs(10)); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - fs::remove_file(script)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_copy_deadline_terminates_stalled_input() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let stalled = repo.path().with_extension("stalled-copy-input"); - if !Command::new("mkfifo").arg(&stalled).status()?.success() { - return Err("mkfifo failed".into()); - } - let script = repo.path().with_extension("stalled-copy"); - fs::write( - &script, - format!( - "#!/bin/sh\ndd if={} of=/dev/null bs=1 2>/dev/null\n", - shell_quote(&stalled.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&script)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions)?; - RECOVERY_COPY_PROGRAMS - .lock() - .map_err(|_| "recovery copy program lock poisoned")? - .push((root.clone(), script.clone().into_os_string())); - RECOVERY_COPY_DURATIONS - .lock() - .map_err(|_| "recovery copy duration lock poisoned")? - .push((root, std::time::Duration::from_secs(1))); - - let started = std::time::Instant::now(); - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected stalled repository copy to exceed its deadline".into()); - }; - - assert!( - error.to_string().contains("repository copy exceeded"), - "{error}" - ); - assert!(started.elapsed() < std::time::Duration::from_secs(10)); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - fs::remove_file(script)?; - fs::remove_file(stalled)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_bounds_hook_output_without_promoting() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - let hook_pid = repo.path().with_extension("noisy-hook-pid"); - let late_side_effect = repo.path().with_extension("noisy-hook-late-effect"); - fs::write( - &hook, - format!( - "#!/bin/sh\nprintf '%s\\n' $$ > {}\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\ntouch {}\n", - shell_quote(&hook_pid.to_string_lossy()), - shell_quote(&late_side_effect.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - - let started = std::time::Instant::now(); - let Err(error) = git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - ) else { - return Err("expected noisy recovery hook to exceed the output bound".into()); - }; - - assert!(error.to_string().contains("Git output bytes exceed")); - assert!( - started.elapsed() < std::time::Duration::from_secs(10), - "recovery waited for the noisy hook instead of terminating its process group" - ); - let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; - let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid noisy hook pid")?; - for _ in 0..200 { - if rustix::process::test_kill_process(pid).is_err() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - assert!( - rustix::process::test_kill_process(pid).is_err(), - "noisy recovery hook survived output-bound termination" - ); - assert!(!late_side_effect.exists()); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - fs::remove_file(hook_pid)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_deadline_terminates_quiet_hook_without_promoting() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - let hook_pid = repo.path().with_extension("hanging-hook-pid"); - let late_side_effect = repo.path().with_extension("hanging-hook-late-effect"); - fs::write( - &hook, - format!( - "#!/bin/sh\nprintf '%s\\n' $$ > {}\nsleep 30\ntouch {}\n", - shell_quote(&hook_pid.to_string_lossy()), - shell_quote(&late_side_effect.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - RECOVERY_COMMAND_DURATIONS - .lock() - .map_err(|_| "recovery command duration lock poisoned")? - .push((root.clone(), std::time::Duration::from_secs(1))); - - let started = std::time::Instant::now(); - let Err(error) = git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - ) else { - return Err("expected quiet recovery hook to exceed the execution deadline".into()); - }; - - assert!(error.to_string().contains("execution deadline"), "{error}"); - assert!( - started.elapsed() < std::time::Duration::from_secs(10), - "recovery waited indefinitely for a quiet hook" - ); - let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; - let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid hanging hook pid")?; - for _ in 0..200 { - if rustix::process::test_kill_process(pid).is_err() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - assert!( - rustix::process::test_kill_process(pid).is_err(), - "quiet recovery hook survived deadline termination" - ); - assert!(!late_side_effect.exists()); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - assert_eq!(recovery_backup_from_pointer(&root)?, None); - fs::remove_file(hook_pid)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_terminates_quiet_hook_descendants_after_git_exits() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - let hook_pid = repo.path().with_extension("quiet-hook-pid"); - let late_side_effect = repo.path().with_extension("quiet-hook-late-effect"); - fs::write( - &hook, - format!( - "#!/bin/sh\n(sh -c 'printf \"%s\\n\" \"$$\" > {}; sleep 30; touch {}') &\nwhile [ ! -s {} ]; do sleep 0.01; done\n", - shell_quote(&hook_pid.to_string_lossy()), - shell_quote(&late_side_effect.to_string_lossy()), - shell_quote(&hook_pid.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - - let started = std::time::Instant::now(); - git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )?; - - assert!( - started.elapsed() < std::time::Duration::from_secs(10), - "recovery waited for a quiet hook descendant holding its output pipe" - ); - let pid = fs::read_to_string(&hook_pid)?.trim().parse::()?; - let pid = rustix::process::Pid::from_raw(pid).ok_or("invalid quiet hook pid")?; - for _ in 0..200 { - if rustix::process::test_kill_process(pid).is_err() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - assert!( - rustix::process::test_kill_process(pid).is_err(), - "quiet recovery hook descendant survived post-exit termination" - ); - assert!(!late_side_effect.exists()); - assert_eq!(git.status()?.operation, None); - fs::remove_file(hook_pid)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_pid_namespace_terminates_setsid_hook_descendants() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - let delayed_write = repo.path().join("escaped-hook-write"); - fs::write( - &hook, - format!( - "#!/bin/sh\nsetsid sh -c 'sleep 1; printf escaped > \"$1\"' bitbygit {} /dev/null 2>&1 &\n", - shell_quote(&delayed_write.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - - git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )?; - std::thread::sleep(std::time::Duration::from_millis(1500)); - - assert!(!delayed_write.exists()); - assert_eq!(git.status()?.operation, None); - Ok(()) - } - - #[test] - fn changed_retained_backup_blocks_later_recovery() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let mut stale_file = fs::OpenOptions::new() - .write(true) - .open(repo.path().join("conflict.txt"))?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - let root = repo.path().canonicalize()?; - let backup = - recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; - stale_file.set_len(0)?; - stale_file.write_all(b"late stale-descriptor write\n")?; - stale_file.sync_all()?; - assert_eq!( - fs::read_to_string(backup.join("conflict.txt"))?, - "late stale-descriptor write\n" - ); - - repo.run_allow_failure(["merge", "other"])?; - let expected = git.recovery_state()?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected changed retained backup to block later recovery".into()); - }; - - assert!(error.to_string().contains("inspect and move or delete it")); - assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); - assert_eq!( - fs::read_to_string(backup.join("conflict.txt"))?, - "late stale-descriptor write\n" - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - Ok(()) - } - - #[test] - fn retained_backup_blocks_automatic_cleanup_without_deleting_late_writes() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let mut stale_file = fs::OpenOptions::new() - .write(true) - .open(repo.path().join("conflict.txt"))?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - let root = repo.path().canonicalize()?; - let backup = - recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; - repo.run_allow_failure(["merge", "other"])?; - let expected = git.recovery_state()?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected retained backup to block automatic cleanup".into()); - }; - stale_file.set_len(0)?; - stale_file.write_all(b"write after blocked cleanup\n")?; - stale_file.sync_all()?; - - assert!( - error.to_string().contains("inspect and move or delete it"), - "{error}" - ); - assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup.clone())); - assert_eq!( - fs::read_to_string(backup.join("conflict.txt"))?, - "write after blocked cleanup\n" - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - Ok(()) - } - - #[test] - fn retained_backup_follows_repository_rename() -> Result<(), Box> { - let (mut repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - let first_backup = recovery_backup_from_pointer(&repo.path().canonicalize()?)? - .ok_or("missing retained recovery backup")?; - let renamed = repo.path().with_extension("renamed"); - fs::rename(repo.path(), &renamed)?; - repo.path = renamed; - repo.run_allow_failure(["merge", "other"])?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected retained backup to block recovery after rename".into()); - }; - assert!(error.to_string().contains("inspect and move or delete it")); - let archived = repo.path().with_extension("archived-recovery-backup"); - fs::rename(&first_backup, &archived)?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - - assert!(archived.is_dir()); - assert!(!recovery_candidate_owner_path(&first_backup).exists()); - assert!(recovery_backup_from_pointer(&repo.path().canonicalize()?)?.is_some()); - fs::remove_dir_all(&archived)?; - Ok(()) - } - - #[test] - fn repeated_retained_backup_cleanup_removes_owner_sidecars() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let mut previous_owner: Option = None; - let mut archives = Vec::new(); - - for cycle in 0..3 { - let expected = git.recovery_state()?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - if let Some(owner) = previous_owner.take() { - assert!(!owner.exists()); - } - let root = repo.path().canonicalize()?; - let backup = recovery_backup_from_pointer(&root)?.ok_or("missing retained backup")?; - let owner = recovery_candidate_owner_path(&backup); - assert!(owner.is_file()); - let archive = repo - .path() - .with_extension(format!("approved-backup-{cycle}")); - fs::rename(&backup, &archive)?; - archives.push(archive); - previous_owner = Some(owner); - if cycle < 2 { - repo.run_allow_failure(["merge", "other"])?; - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - } - } - - for archive in archives { - fs::remove_dir_all(archive)?; - } - Ok(()) - } - - #[test] - fn same_path_replacement_does_not_own_retained_backup() -> Result<(), Box> { - let (mut repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - let root = repo.path().canonicalize()?; - let backup = - recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; - let former = root.with_extension("former"); - fs::rename(&root, &former)?; - repo.path = former.clone(); - let output = Command::new("git") - .args(["init", "-b", "main"]) - .arg(&root) - .output()?; - if !output.status.success() { - return Err(format!( - "failed to create replacement repository: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - - remove_previous_recovery_backup(&root.canonicalize()?)?; - - assert!(backup.is_dir()); - fs::remove_dir_all(&root)?; - fs::rename(&former, &root)?; - repo.path = root; - Ok(()) - } - - #[test] - fn copied_repository_does_not_own_retained_backup() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - let root = repo.path().canonicalize()?; - let backup = - recovery_backup_from_pointer(&root)?.ok_or("missing retained recovery backup")?; - let copy = TempRepo::new()?; - let output = Command::new("cp") - .args(["-a", "--"]) - .arg(root.join(".")) - .arg(copy.path()) - .output()?; - if !output.status.success() { - return Err(format!( - "repository copy failed: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - let copied_root = copy.path().canonicalize()?; - - let Err(error) = remove_previous_recovery_backup(&copied_root) else { - return Err("expected copied backup pointer to be rejected".into()); - }; - - assert!(error.to_string().contains("backup pointer is invalid")); - assert!(backup.is_dir()); - assert_eq!(recovery_backup_from_pointer(&root)?, Some(backup)); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn capability_probe_cleanup_rejects_synchronized_path_replacement() -> Result<(), Box> - { - use std::sync::{Arc, Mutex}; - - let parent = TempRepo::new()?; - let replaced = Arc::new(Mutex::new(None)); - let hook_replaced = Arc::clone(&replaced); - let hook_parent = parent.path(); - let hook: RecoveryCaptureHook = Box::new(move || { - let Ok(entries) = fs::read_dir(&hook_parent) else { - return; - }; - for entry in entries.flatten() { - if !entry - .file_name() - .as_encoded_bytes() - .starts_with(b".bitbygit-recovery-capability-") - { - continue; - } - let probe = entry.path(); - let displaced = probe.with_extension("displaced"); - if fs::rename(&probe, &displaced).is_ok() && fs::create_dir(&probe).is_ok() { - let _ = fs::write(probe.join("replacement-sentinel"), "preserve\n"); - if let Ok(mut paths) = hook_replaced.lock() { - *paths = Some((probe, displaced)); - } - } - return; - } - }); - RECOVERY_PROBE_CLEANUP_HOOKS - .lock() - .map_err(|_| "recovery probe cleanup hook lock poisoned")? - .push((parent.path(), hook)); - - let Err(_error) = ensure_recovery_platform_capabilities(&parent.path()) else { - return Err("expected substituted capability probe cleanup to fail closed".into()); - }; - - let (probe, displaced) = replaced - .lock() - .map_err(|_| "replaced probe lock poisoned")? - .take() - .ok_or("capability probe hook did not replace a probe")?; - assert_eq!( - fs::read_to_string(probe.join("replacement-sentinel"))?, - "preserve\n" - ); - fs::remove_dir_all(probe)?; - fs::remove_dir_all(displaced)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn capability_probe_enforces_deadline_and_output_bounds() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - for (name, body, expected) in [ - ( - "quiet", - "#!/bin/sh\nsleep 30\n", - "capability probe exceeded", - ), - ( - "noisy", - "#!/bin/sh\ntrap '' PIPE\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null || true\nsleep 30\n", - "capability probe output bytes exceed", - ), - ] { - let parent = TempRepo::new()?; - let script = parent.path().with_extension(format!("{name}-probe")); - fs::write(&script, body)?; - fs::set_permissions(&script, fs::Permissions::from_mode(0o755))?; - RECOVERY_PROBE_PROGRAMS - .lock() - .map_err(|_| "recovery probe program lock poisoned")? - .push((parent.path(), script.clone().into_os_string())); - RECOVERY_PROBE_DURATIONS - .lock() - .map_err(|_| "recovery probe duration lock poisoned")? - .push((parent.path(), std::time::Duration::from_millis(250))); - - let started = std::time::Instant::now(); - let Err(error) = ensure_recovery_platform_capabilities(&parent.path()) else { - return Err(format!("expected {name} capability probe to be bounded").into()); - }; - - assert!(error.to_string().contains(expected), "{error}"); - assert!(started.elapsed() < std::time::Duration::from_secs(5)); - assert!(fs::read_dir(parent.path())?.next().is_none()); - fs::remove_file(script)?; - } - Ok(()) - } - - #[test] - fn exact_recovery_fails_closed_before_creating_candidate() -> Result<(), Box> { - use std::sync::{Arc, atomic::AtomicBool}; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let root = repo.path().canonicalize()?; - let before = RecoveryGeneration::capture(&root)?; - let executed = Arc::new(AtomicBool::new(false)); - let hook_executed = Arc::clone(&executed); - let hook: RecoveryCaptureHook = Box::new(move || { - hook_executed.store(true, Ordering::Release); - }); - RECOVERY_EXECUTION_HOOKS - .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((root.clone(), hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected speculative recovery to fail closed".into()); - }; - - assert!( - matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), - "{error}" - ); - assert!(!executed.load(Ordering::Acquire)); - assert_eq!(RecoveryGeneration::capture(&root)?, before); - assert_eq!(git.recovery_state()?, expected); - let parent = root.parent().ok_or("test repository has no parent")?; - assert!(fs::read_dir(parent)?.all(|entry| { - entry.is_ok_and(|entry| { - !entry - .file_name() - .as_encoded_bytes() - .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) - }) - })); - Ok(()) - } - - #[test] - fn exact_recovery_fails_closed_when_platform_capabilities_are_unavailable() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let root = repo.path().canonicalize()?; - let before = RecoveryGeneration::capture(&root)?; - RECOVERY_CAPABILITY_FAILURES - .lock() - .map_err(|_| "recovery capability failure lock poisoned")? - .push(root.clone()); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected unavailable recovery capabilities to block execution".into()); - }; - - assert!( - matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), - "{error}" - ); - assert_eq!(RecoveryGeneration::capture(&root)?, before); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data() - -> Result<(), Box> { - const CHILD: &str = "BITBYGIT_TEST_NESTED_RECOVERY_MOUNT"; - - if env::var_os(CHILD).is_none() { - let probe_root = TempRepo::new()?; - let probe_source = probe_root.path().join("source"); - let probe_target = probe_root.path().join("target"); - fs::create_dir(&probe_source)?; - fs::create_dir(&probe_target)?; - let probe = Command::new("unshare") - .args([ - "--user", - "--map-root-user", - "--mount", - "--fork", - "mount", - "--bind", - ]) - .arg(&probe_source) - .arg(&probe_target) - .output()?; - if !probe.status.success() { - if env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { - return Err(format!( - "nested-mount recovery capabilities are required for this test: {}", - String::from_utf8_lossy(&probe.stderr).trim() - ) - .into()); - } - return Ok(()); - } - let output = Command::new("unshare") - .args(["--user", "--map-root-user", "--mount", "--fork"]) - .arg(env::current_exe()?) - .args([ - "--exact", - "tests::exact_recovery_rejects_nested_bind_mount_and_preserves_mounted_data", - "--nocapture", - ]) - .env(CHILD, "1") - .output()?; - assert!( - output.status.success(), - "nested-mount recovery child failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return Ok(()); - } - - use std::os::unix::fs::MetadataExt; - - struct BindMountGuard(Option); - - impl BindMountGuard { - fn unmount(&mut self) -> Result<(), Box> { - let Some(target) = self.0.take() else { - return Ok(()); - }; - let status = Command::new("umount").arg(&target).status()?; - if !status.success() { - self.0 = Some(target); - return Err("nested bind unmount failed".into()); - } - Ok(()) - } - } - - impl Drop for BindMountGuard { - fn drop(&mut self) { - if let Some(target) = &self.0 { - let _result = Command::new("umount").arg(target).status(); - } - } - } - - let (repo, _original_head) = prepare_merge_conflict()?; - let source = repo.path().with_extension("nested-mount-source"); - let target = repo.path().join("nested-mount"); - fs::create_dir(&source)?; - fs::create_dir(&target)?; - fs::write(source.join("sentinel"), "must survive recovery\n")?; - assert_eq!(fs::metadata(&source)?.dev(), fs::metadata(&target)?.dev()); - let bind_mount = |target: &Path| -> Result> { - let mount = Command::new("mount") - .args(["--bind"]) - .arg(&source) - .arg(target) - .output()?; - if !mount.status.success() { - return Err(format!( - "nested bind mount failed: {}", - String::from_utf8_lossy(&mount.stderr).trim() - ) - .into()); - } - Ok(BindMountGuard(Some(target.to_owned()))) - }; - let mut guard = bind_mount(&target)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - - for attempt in 1..=2 { - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err( - format!("nested mount recovery attempt {attempt} was not blocked").into(), - ); - }; - assert!( - error.to_string().contains("mounted filesystem entry"), - "{error}" - ); - assert_eq!(git.recovery_state()?, expected); - assert_eq!( - fs::read_to_string(source.join("sentinel"))?, - "must survive recovery\n" - ); - } - - guard.unmount()?; - let root = repo.path().canonicalize()?; - let before = RecoveryGeneration::capture(&root)?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected speculative recovery to remain unavailable".into()); - }; - assert!( - matches!(&error, GitError::Blocked { message } if message.starts_with(RECOVERY_CAPABILITY_UNAVAILABLE)), - "{error}" - ); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(RecoveryGeneration::capture(&root)?, before); - assert_eq!( - fs::read_to_string(source.join("sentinel"))?, - "must survive recovery\n" - ); - - fs::remove_dir_all(source)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_rejects_symlinked_mutable_git_storage() -> Result<(), Box> { - use std::os::unix::fs::symlink; - - for relative in ["objects", "refs", "index"] { - let (repo, _original_head) = prepare_merge_conflict()?; - let storage = repo.path().join(".git").join(relative); - let external = repo.path().with_extension(format!("external-{relative}")); - fs::rename(&storage, &external)?; - symlink(&external, &storage)?; - - let Err(error) = Git::new(repo.path()).ensure_recovery_supported() else { - return Err(format!("expected symlinked .git/{relative} to block recovery").into()); - }; - assert!(error.to_string().contains("symlinked Git storage")); - assert!(external.exists()); - - fs::remove_file(&storage)?; - fs::rename(&external, &storage)?; - } - Ok(()) - } - - #[test] - fn recovery_rejects_local_alternate_object_storage() -> Result<(), Box> { - let source = initialized_repo()?; - let clone = TempRepo::new()?; - let output = Command::new("git") - .args(["clone", "--shared", "--"]) - .arg(source.path()) - .arg(clone.path()) - .output()?; - if !output.status.success() { - return Err(format!( - "shared git clone failed: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - assert!(clone.path().join(".git/objects/info/alternates").is_file()); - - let Err(error) = Git::new(clone.path()).ensure_recovery_supported() else { - return Err("expected shared clone alternates to block recovery".into()); - }; - - assert!(error.to_string().contains("alternate object storage")); - Ok(()) - } - - #[test] - fn exact_recovery_rejects_relative_alternate_removed_after_preview() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let alternates = repo.path().join(".git/objects/info/alternates"); - fs::write(&alternates, "../../external-objects\n")?; - fs::create_dir(repo.path().join("external-objects"))?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - fs::remove_file(&alternates)?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected removed Git alternates to invalidate recovery preview".into()); - }; - - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_pointer_race_cannot_overwrite_symlink_target() -> Result<(), Box> { - use std::os::unix::fs::symlink; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let pointer = recovery_backup_pointer_path(&root)?; - let victim = repo.path().with_extension("pointer-victim"); - fs::write(&victim, "must remain unchanged\n")?; - let hook_pointer = pointer.clone(); - let hook_victim = victim.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let Some(root) = hook_pointer.parent().and_then(Path::parent) else { - return; - }; - let Some(parent) = root.parent() else { - return; - }; - let Ok(root_metadata) = fs::symlink_metadata(root) else { - return; - }; - let root_identity = recovery_file_identity(&root_metadata); - let mut expected_owner = Vec::with_capacity(16); - expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); - expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); - let Ok(entries) = fs::read_dir(parent) else { - return; - }; - for entry in entries.flatten() { - if entry - .file_name() - .as_encoded_bytes() - .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) - && fs::read(recovery_candidate_owner_path(&entry.path())) - .is_ok_and(|owner| owner.starts_with(&expected_owner)) - { - let _ = symlink( - &hook_victim, - entry.path().join(".git").join(RECOVERY_BACKUP_POINTER), - ); - } - } - }); - RECOVERY_SIDECAR_HOOKS - .lock() - .map_err(|_| "recovery sidecar hook lock poisoned")? - .push((pointer.clone(), hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected hostile backup pointer to block recovery".into()); - }; - assert!(error.to_string().contains("backup pointer")); - assert_eq!(fs::read_to_string(&victim)?, "must remain unchanged\n"); - assert!(!pointer.exists()); - fs::remove_file(victim)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_owner_sidecar_cannot_overwrite_symlink_target() -> Result<(), Box> { - use std::os::unix::fs::symlink; - - let repo = initialized_repo()?; - let owner = repo.path().with_extension("candidate.owner"); - let victim = repo.path().with_extension("owner-victim"); - fs::write(&victim, "must remain unchanged\n")?; - symlink(&victim, &owner)?; - - let Err(error) = write_new_recovery_sidecar(&owner, b"replacement", "record owner") else { - return Err("expected hostile owner sidecar to be rejected".into()); - }; - assert!(error.to_string().contains("record owner")); - assert_eq!(fs::read_to_string(&victim)?, "must remain unchanged\n"); - fs::remove_file(owner)?; - fs::remove_file(victim)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_backup_sidecars_reject_fifo_and_oversized_files() -> Result<(), Box> { - let fifo_repo = initialized_repo()?; - let fifo_root = fifo_repo.path().canonicalize()?; - let fifo_pointer = recovery_backup_pointer_path(&fifo_root)?; - if !Command::new("mkfifo") - .arg(&fifo_pointer) - .status()? - .success() - { - return Err("mkfifo failed".into()); - } - let started = std::time::Instant::now(); - let Err(error) = recovery_backup_from_pointer(&fifo_root) else { - return Err("expected FIFO backup pointer to be rejected".into()); - }; - assert!(matches!(error, GitError::Blocked { .. })); - assert!(started.elapsed() < std::time::Duration::from_secs(2)); - fs::remove_file(fifo_pointer)?; - - let oversized_repo = initialized_repo()?; - let oversized_root = oversized_repo.path().canonicalize()?; - let oversized_pointer = recovery_backup_pointer_path(&oversized_root)?; - fs::write( - &oversized_pointer, - vec![0_u8; MAX_RECOVERY_BACKUP_POINTER_BYTES as usize + 1], - )?; - let Err(error) = recovery_backup_from_pointer(&oversized_root) else { - return Err("expected oversized backup pointer to be rejected".into()); - }; - assert!(matches!(error, GitError::Blocked { .. })); - fs::remove_file(oversized_pointer)?; - - let owner_repo = initialized_repo()?; - let owner_root = owner_repo.path().canonicalize()?; - let parent = owner_root.parent().ok_or("test repository has no parent")?; - let backup = parent.join(format!( - "{RECOVERY_CANDIDATE_PREFIX}hostile-owner-{}", - NEXT_REPO_ID.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&backup)?; - let backup_identity = recovery_file_identity(&fs::symlink_metadata(&backup)?); - let root_identity = recovery_file_identity(&fs::symlink_metadata(&owner_root)?); - let mut identity = Vec::with_capacity(RECOVERY_BACKUP_IDENTITY_BYTES); - for value in [ - backup_identity.0, - backup_identity.1, - root_identity.0, - root_identity.1, - ] { - identity.extend_from_slice(&value.to_le_bytes()); - } - identity.extend_from_slice(&[0_u8; 32]); - let owner = recovery_candidate_owner_path(&backup); - fs::write(&owner, vec![0_u8; RECOVERY_BACKUP_IDENTITY_BYTES + 1])?; - let mut pointer_contents = backup.as_os_str().as_encoded_bytes().to_vec(); - pointer_contents.push(0); - pointer_contents.extend_from_slice(&identity); - pointer_contents.extend_from_slice(&[0_u8; RECOVERY_GENERATION_DIGEST_BYTES]); - fs::write(recovery_backup_pointer_path(&owner_root)?, pointer_contents)?; - - let Err(error) = recovery_backup_from_pointer(&owner_root) else { - return Err("expected oversized backup owner sidecar to be rejected".into()); - }; - assert!(matches!(error, GitError::Blocked { .. })); - assert!(backup.is_dir()); - fs::remove_file(recovery_backup_pointer_path(&owner_root)?)?; - fs::remove_file(owner)?; - fs::remove_dir(backup)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_candidate_is_private_under_shared_parent() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let parent = TempRepo::new()?; - fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o755))?; - let root = parent.path().join("private-repository"); - fs::create_dir(&root)?; - fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?; - fs::write(root.join("secret.txt"), "private\n")?; - fs::set_permissions(root.join("secret.txt"), fs::Permissions::from_mode(0o644))?; - - let (candidate, _) = create_recovery_candidate(&parent.path(), &root)?; - - assert_eq!( - fs::metadata(parent.path())?.permissions().mode() & 0o777, - 0o755 - ); - assert_eq!(fs::metadata(&root)?.permissions().mode() & 0o777, 0o700); - assert_eq!( - fs::metadata(&candidate)?.permissions().mode() & 0o777, - 0o700 - ); - assert_eq!( - fs::metadata(recovery_candidate_owner_path(&candidate))? - .permissions() - .mode() - & 0o777, - 0o600 - ); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_generation_does_not_open_fifo_or_symlink_replacements() -> Result<(), Box> - { - use std::os::unix::fs::{FileTypeExt, symlink}; - - for replacement_kind in ["fifo", "symlink"] { - let repo = initialized_repo()?; - let path = repo.path().join("tracked.txt"); - fs::write(&path, "tracked\n")?; - let replacement = repo.path().join(format!("replacement-{replacement_kind}")); - if replacement_kind == "fifo" { - if !Command::new("mkfifo").arg(&replacement).status()?.success() { - return Err("mkfifo failed".into()); - } - } else { - symlink(repo.path().join(".git/config"), &replacement)?; - } - let destination = path.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::rename(replacement, destination); - }); - RECOVERY_FILE_OPEN_HOOKS - .lock() - .map_err(|_| "recovery file-open hook lock poisoned")? - .push((path.clone(), hook)); - - let Err(error) = RecoveryGeneration::capture(&repo.path()) else { - return Err( - format!("expected {replacement_kind} replacement to be rejected").into(), - ); - }; - assert!(matches!(error, GitError::Blocked { .. })); - let file_type = fs::symlink_metadata(path)?.file_type(); - assert!(if replacement_kind == "fifo" { - file_type.is_fifo() - } else { - file_type.is_symlink() - }); - } - Ok(()) - } - - #[test] - fn recovery_generation_bounds_file_growth_after_open() -> Result<(), Box> { - let repo = initialized_repo()?; - let path = repo.path().join("growing.txt"); - fs::write(&path, "before\n")?; - let growing = path.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - if let Ok(mut file) = fs::OpenOptions::new().append(true).open(growing) { - let _ = file.write_all(b"after\n"); - } - }); - RECOVERY_GENERATION_FILE_READ_HOOKS - .lock() - .map_err(|_| "recovery generation read hook lock poisoned")? - .push((path, hook)); - - let Err(error) = RecoveryGeneration::capture(&repo.path()) else { - return Err("expected file growth during generation capture to be rejected".into()); - }; - assert!(error.to_string().contains("grew while it was captured")); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_generation_rejects_hard_links() -> Result<(), Box> { - let repo = initialized_repo()?; - let original = repo.path().join("original.txt"); - fs::write(&original, "linked\n")?; - fs::hard_link(&original, repo.path().join("linked.txt"))?; - - let Err(error) = RecoveryGeneration::capture(&repo.path()) else { - return Err("expected hard-linked files to block atomic recovery".into()); - }; - assert!(error.to_string().contains("hard-linked file")); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_rejects_hook_created_special_entry_before_promotion() -> Result<(), Box> - { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - fs::write(&hook, "#!/bin/sh\nmkfifo hook-created-special-entry\n")?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - - let Err(error) = git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - ) else { - return Err("expected hook-created special entry to block promotion".into()); - }; - - assert!( - error.to_string().contains("special filesystem entry"), - "{error}" - ); - assert_eq!(git.recovery_state()?, expected); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - assert!(!repo.path().join("hook-created-special-entry").exists()); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_rejects_replaced_candidate_identity() -> Result<(), Box> { - use std::sync::{Arc, Mutex}; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let parent = root - .parent() - .ok_or("test repository has no parent")? - .to_owned(); - let root_identity = recovery_path_identity(&root)?; - let replaced = Arc::new(Mutex::new(None)); - let hook_replaced = Arc::clone(&replaced); - let hook: RecoveryCaptureHook = Box::new(move || { - let Ok(entries) = fs::read_dir(&parent) else { - return; - }; - for entry in entries.flatten() { - let candidate = entry.path(); - let owner = recovery_candidate_owner_path(&candidate); - let mut expected_owner = Vec::with_capacity(16); - expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); - expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); - if !fs::read(&owner).is_ok_and(|bytes| bytes.starts_with(&expected_owner)) { - continue; - } - let displaced = candidate.with_extension("displaced"); - if fs::rename(&candidate, &displaced).is_err() - || fs::create_dir(&candidate).is_err() - || fs::create_dir(candidate.join(".git")).is_err() - || fs::write(candidate.join("replacement-sentinel"), "preserve\n").is_err() - { - return; - } - if let Ok(mut paths) = hook_replaced.lock() { - *paths = Some((candidate, displaced, owner)); - } - return; - } - }); - RECOVERY_PROMOTION_HOOKS - .lock() - .map_err(|_| "recovery promotion hook lock poisoned")? - .push((root, hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected replaced recovery candidate to block promotion".into()); - }; - - assert!(error.to_string().contains("candidate changed"), "{error}"); - assert_eq!(git.recovery_state()?, expected); - let (candidate, displaced, owner) = replaced - .lock() - .map_err(|_| "replaced candidate path lock poisoned")? - .take() - .ok_or("promotion hook did not replace the candidate")?; - assert_eq!( - fs::read_to_string(candidate.join("replacement-sentinel"))?, - "preserve\n" - ); - fs::remove_dir_all(candidate)?; - fs::remove_dir_all(displaced)?; - let _ = fs::remove_file(owner); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_does_not_exchange_a_synchronized_root_replacement() - -> Result<(), Box> { - let (mut repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let root = repo.path().canonicalize()?; - let parent = root.parent().ok_or("test repository has no parent")?; - let baseline = RecoveryGeneration::capture(&root)?; - let root_identity = recovery_path_identity(&root)?; - let (candidate, _) = create_recovery_candidate(parent, &root)?; - let candidate_identity = recovery_path_identity(&candidate)?; - let mut transaction = RecoveryTransaction { - root: root.clone(), - backup_pointer: recovery_backup_pointer_path(&candidate)?, - candidate, - baseline, - root_identity, - candidate_identity, - keep_candidate: false, - }; - transaction.copy_repository()?; - let displaced = root.with_extension("promotion-displaced"); - let hook_root = root.clone(); - let hook_displaced = displaced.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - if fs::rename(&hook_root, &hook_displaced).is_ok() && fs::create_dir(&hook_root).is_ok() - { - let _ = fs::write(hook_root.join("replacement-sentinel"), "preserve\n"); - } - }); - RECOVERY_PROMOTION_HOOKS - .lock() - .map_err(|_| "recovery promotion hook lock poisoned")? - .push((root.clone(), hook)); - - let Err(error) = transaction.promote(&git, &expected) else { - return Err("expected synchronized root replacement to block promotion".into()); - }; - - assert!( - error - .to_string() - .contains("changed before atomic promotion") - ); - assert_eq!( - fs::read_to_string(root.join("replacement-sentinel"))?, - "preserve\n" - ); - fs::remove_dir_all(&root)?; - fs::rename(&displaced, &root)?; - repo.path = root; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_does_not_exchange_a_synchronized_candidate_replacement() - -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let root = repo.path().canonicalize()?; - let parent = root.parent().ok_or("test repository has no parent")?; - let baseline = RecoveryGeneration::capture(&root)?; - let root_identity = recovery_path_identity(&root)?; - let (candidate, _) = create_recovery_candidate(parent, &root)?; - let candidate_identity = recovery_path_identity(&candidate)?; - let mut transaction = RecoveryTransaction { - root: root.clone(), - backup_pointer: recovery_backup_pointer_path(&candidate)?, - candidate: candidate.clone(), - baseline, - root_identity, - candidate_identity, - keep_candidate: false, - }; - transaction.copy_repository()?; - let displaced = candidate.with_extension("promotion-displaced"); - let hook_candidate = candidate.clone(); - let hook_displaced = displaced.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - if fs::rename(&hook_candidate, &hook_displaced).is_ok() - && fs::create_dir(&hook_candidate).is_ok() - && fs::create_dir(hook_candidate.join(".git")).is_ok() - { - let _ = fs::write(hook_candidate.join("replacement-sentinel"), "preserve\n"); - } - }); - RECOVERY_PROMOTION_HOOKS - .lock() - .map_err(|_| "recovery promotion hook lock poisoned")? - .push((root, hook)); - - let Err(error) = transaction.promote(&git, &expected) else { - return Err("expected synchronized candidate replacement to block promotion".into()); - }; - - assert!( - error - .to_string() - .contains("changed before atomic promotion") - ); - assert_eq!( - fs::read_to_string(candidate.join("replacement-sentinel"))?, - "preserve\n" - ); - fs::remove_dir_all(&candidate)?; - fs::remove_dir_all(&displaced)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_never_rolls_back_through_a_replaced_root() -> Result<(), Box> { - let (mut repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let root = repo.path().canonicalize()?; - let parent = root.parent().ok_or("test repository has no parent")?; - let baseline = RecoveryGeneration::capture(&root)?; - let root_identity = recovery_path_identity(&root)?; - let (candidate, _) = create_recovery_candidate(parent, &root)?; - let candidate_identity = recovery_path_identity(&candidate)?; - let mut transaction = RecoveryTransaction { - root: root.clone(), - backup_pointer: recovery_backup_pointer_path(&candidate)?, - candidate: candidate.clone(), - baseline, - root_identity, - candidate_identity, - keep_candidate: false, - }; - transaction.copy_repository()?; - let installed = root.with_extension("installed-recovery"); - let hook_root = root.clone(); - let hook_installed = installed.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - if fs::rename(&hook_root, &hook_installed).is_ok() && fs::create_dir(&hook_root).is_ok() - { - let _ = fs::write(hook_root.join("replacement-sentinel"), "preserve\n"); - } - }); - RECOVERY_POST_EXCHANGE_HOOKS - .lock() - .map_err(|_| "recovery post-exchange hook lock poisoned")? - .push((root.clone(), hook)); - - let Err(error) = transaction.promote(&git, &expected) else { - return Err("expected post-exchange root replacement to block promotion".into()); - }; - - assert!(error.to_string().contains("no rollback was attempted")); - assert_eq!( - fs::read_to_string(root.join("replacement-sentinel"))?, - "preserve\n" - ); - assert_eq!(recovery_path_identity(&candidate)?, root_identity); - fs::remove_dir_all(&root)?; - fs::rename(&candidate, &root)?; - fs::remove_dir_all(installed)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); - repo.path = root; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn recovery_cleanup_rejects_synchronized_path_replacement() -> Result<(), Box> { - let repo = initialized_repo()?; - let root = repo.path().canonicalize()?; - let parent = root.parent().ok_or("test repository has no parent")?; - let (candidate, _) = create_recovery_candidate(parent, &root)?; - fs::write( - candidate.join("owned-data"), - "remove only this generation\n", - )?; - let identity = recovery_path_identity(&candidate)?; - let displaced = candidate.with_extension("cleanup-displaced"); - let hook_candidate = candidate.clone(); - let hook_displaced = displaced.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - if fs::rename(&hook_candidate, &hook_displaced).is_ok() - && fs::create_dir(&hook_candidate).is_ok() - { - let _ = fs::write(hook_candidate.join("replacement-sentinel"), "preserve\n"); - } - }); - RECOVERY_CLEANUP_HOOKS - .lock() - .map_err(|_| "recovery cleanup hook lock poisoned")? - .push((candidate.clone(), hook)); - - let Err(error) = remove_recovery_directory(&candidate, identity) else { - return Err("expected synchronized cleanup replacement to be rejected".into()); - }; - - assert!( - error.to_string().contains("changed before removal"), - "{error}" - ); - assert_eq!( - fs::read_to_string(candidate.join("replacement-sentinel"))?, - "preserve\n" - ); - fs::remove_dir_all(&candidate)?; - fs::remove_dir_all(&displaced)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&candidate)); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_rolls_back_concurrent_xattr_change() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let conflict = repo.path().join("conflict.txt"); - let changed = conflict.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = xattr::set(changed, "user.bitbygit-test", b"changed"); - }); - RECOVERY_PROMOTION_HOOKS - .lock() - .map_err(|_| "recovery promotion hook lock poisoned")? - .push((repo.path().canonicalize()?, hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected concurrent xattr change to roll back recovery".into()); - }; - assert!( - error - .to_string() - .contains("metadata changed during atomic recovery promotion") - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - assert_eq!( - xattr::get(conflict, "user.bitbygit-test")?, - Some(b"changed".to_vec()) - ); - Ok(()) - } - - #[test] - fn recovery_rejects_external_git_storage_paths() -> Result<(), Box> { - if let (Some(repo), Some(variable)) = ( - env::var_os("BITBYGIT_TEST_EXTERNAL_GIT_REPO"), - env::var_os("BITBYGIT_TEST_EXTERNAL_GIT_VARIABLE"), - ) { - let variable = variable.to_string_lossy(); - let irrelevant_state = RecoveryState { - operation: Some(RepositoryOperation::Merge), - head: HeadTarget { - oid: None, - reference: None, - }, - index: Vec::new(), - worktree: Vec::new(), - metadata: Vec::new(), - refs: Vec::new(), - execution: RecoveryExecutionState { - config: Vec::new(), - config_files: Vec::new(), - attributes: Vec::new(), - hooks_location: RecoveryHookLocation::Repository(PathBuf::new()), - hooks: Vec::new(), - }, - }; - let Err(error) = Git::new(repo).recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Abort, - &irrelevant_state, - ) else { - return Err(format!("expected inherited {variable} to block recovery").into()); - }; - assert!(error.to_string().contains(variable.as_ref())); - return Ok(()); - } - - let (repo, _original_head) = prepare_merge_conflict()?; - let external_index = repo.path().with_extension("external-index"); - fs::copy(repo.path().join(".git/index"), &external_index)?; - let index_before = fs::read(&external_index)?; - let external_objects = repo.path().with_extension("external-objects"); - fs::create_dir(&external_objects)?; - fs::write(external_objects.join("sentinel"), "unchanged\n")?; - let objects_before = RecoveryGeneration::capture(&external_objects)?; - - for (variable, external_path) in [ - ("GIT_INDEX_FILE", external_index.as_path()), - ("GIT_OBJECT_DIRECTORY", external_objects.as_path()), - ] { - let output = Command::new(env::current_exe()?) - .args([ - "--exact", - "tests::recovery_rejects_external_git_storage_paths", - "--nocapture", - ]) - .env("BITBYGIT_TEST_EXTERNAL_GIT_REPO", repo.path()) - .env("BITBYGIT_TEST_EXTERNAL_GIT_VARIABLE", variable) - .env(variable, external_path) - .output()?; - assert!( - output.status.success(), - "child test for {variable} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - assert_eq!(fs::read(external_index)?, index_before); - assert_eq!( - RecoveryGeneration::capture(&external_objects)?, - objects_before - ); - fs::remove_file(repo.path().with_extension("external-index"))?; - fs::remove_dir_all(external_objects)?; - Ok(()) - } - - #[test] - fn exact_recovery_rejects_state_changed_after_preview() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - repo.write("conflict.txt", "changed after preview\n")?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected changed recovery state to be rejected".into()); - }; - - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn exact_recovery_rejects_new_external_hooks_path_after_preview() -> Result<(), Box> - { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let hooks = repo.path().with_extension("external-hooks"); - let marker = repo.path().with_extension("external-hook-ran"); - fs::create_dir(&hooks)?; - let hook = hooks.join("reference-transaction"); - fs::write( - &hook, - format!( - "#!/bin/sh\ntouch {}\n", - shell_quote(&marker.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - repo.run_args(&["config", "core.hooksPath", hooks.to_string_lossy().as_ref()])?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected a new external hooks path to invalidate the preview".into()); - }; - - assert!(error.to_string().contains("state changed after preview")); - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - fs::remove_dir_all(hooks)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_rejects_repository_hook_path_with_symlinked_parent() -> Result<(), Box> { - use std::os::unix::fs::symlink; - - let (repo, _original_head) = prepare_merge_conflict()?; - let external = repo.path().with_extension("external-hooks-parent"); - fs::create_dir(&external)?; - symlink(&external, repo.path().join("hooks"))?; - repo.run(["config", "core.hooksPath", "hooks/subdir"])?; - - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected a symlinked hook parent to block recovery".into()); - }; - - assert!( - error.to_string().contains("symlinked or non-directory") - || error - .to_string() - .contains("repository-local hook directory") - ); - assert_eq!( - Git::new(repo.path()).status()?.operation, - Some(RepositoryOperation::Merge) - ); - fs::remove_dir_all(external)?; - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn exact_recovery_rejects_hook_parent_replaced_after_final_snapshot() - -> Result<(), Box> { - use std::os::unix::fs::{PermissionsExt, symlink}; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - fs::create_dir_all(repo.path().join("hooks/subdir"))?; - repo.run(["config", "core.hooksPath", "hooks/subdir"])?; - let external = repo.path().with_extension("late-external-hooks"); - let marker = repo.path().with_extension("late-external-hook-ran"); - fs::create_dir(&external)?; - let external_hook = external.join("reference-transaction"); - fs::write( - &external_hook, - format!( - "#!/bin/sh\ntouch {}\n", - shell_quote(&marker.to_string_lossy()) - ), + static NEXT_REPO_ID: AtomicUsize = AtomicUsize::new(0); + + #[test] + fn parses_clean_status() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", )?; - let mut permissions = fs::metadata(&external_hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&external_hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - fs::remove_dir_all(external)?; - return Ok(()); - } - let root = repo.path().canonicalize()?; - let parent = root - .parent() - .ok_or("test repository has no parent")? - .to_owned(); - let root_identity = recovery_path_identity(&root)?; - let replacement = external.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let Ok(entries) = fs::read_dir(parent) else { - return; - }; - for entry in entries.flatten() { - let candidate = entry.path(); - let owner = recovery_candidate_owner_path(&candidate); - let mut expected_owner = Vec::with_capacity(16); - expected_owner.extend_from_slice(&root_identity.0.to_le_bytes()); - expected_owner.extend_from_slice(&root_identity.1.to_le_bytes()); - if !fs::read(owner).is_ok_and(|bytes| bytes.starts_with(&expected_owner)) { - continue; - } - let hooks = candidate.join("hooks"); - if fs::rename(&hooks, candidate.join("original-hooks")).is_ok() { - let _ = symlink(&replacement, hooks); - } - return; - } - }); - RECOVERY_EXECUTION_HOOKS - .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((root, hook)); - - let Err(error) = git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - ) else { - return Err("expected the late symlinked hook parent to block recovery".into()); - }; - assert!( - error.to_string().contains("symlinked or non-directory") - || error - .to_string() - .contains("changed before isolated recovery execution"), - "{error}" - ); - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - fs::remove_dir_all(external)?; + assert!(status.is_clean()); + assert_eq!(status.branch.head, Head::Branch("main".to_owned())); + assert_eq!(status.branch.upstream, Some("origin/main".to_owned())); + assert_eq!(status.branch.ahead, 0); + assert_eq!(status.branch.behind, 0); + assert_eq!(status.operation, None); Ok(()) } - #[cfg(unix)] #[test] - fn exact_recovery_rejects_repository_attributes_changed_after_preview() - -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let marker = repo.path().with_extension("late-filter-ran"); - repo.run_args(&[ - "config", - "filter.marker.smudge", - &format!("touch {}; cat", shell_quote(&marker.to_string_lossy())), - ])?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - fs::write( - git.git_path("info/attributes")?, - "conflict.txt filter=marker\n", + fn parses_dirty_unstaged_file() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n1 .M N... 100644 100644 100644 abc abc README.md\n", )?; - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected changed repository attributes to invalidate the preview".into()); - }; - - assert!(error.to_string().contains("state changed after preview")); - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.unstaged_files().len(), 1); + assert_eq!(status.entries[0].path, PathBuf::from("README.md")); + assert_eq!(status.entries[0].worktree, ChangeKind::Modified); Ok(()) } #[test] - fn exact_recovery_rejects_untracked_worktree_attributes_added_after_preview() - -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - fs::write( - repo.path().join(".gitattributes"), - "conflict.txt filter=late\n", + fn parses_staged_file() -> Result<(), Box> { + let status = parse_status( + "# branch.oid 1234567\n# branch.head main\n1 A. N... 000000 100644 100644 zero abc src/main.rs\n", )?; - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected untracked worktree attributes to invalidate the preview".into()); - }; - - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.staged_files().len(), 1); + assert_eq!(status.entries[0].index, ChangeKind::Added); Ok(()) } #[test] - fn recovery_rejects_configured_user_attributes_file() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let attributes = repo.path().with_extension("external-attributes"); - fs::write(&attributes, "conflict.txt filter=external\n")?; - repo.run_args(&[ - "config", - "core.attributesFile", - attributes.to_string_lossy().as_ref(), - ])?; - - let Err(error) = Git::new(repo.path()).recovery_state() else { - return Err("expected configured user attributes to block recovery preview".into()); - }; + fn parses_untracked_file() -> Result<(), Box> { + let status = parse_status("# branch.head main\n? notes.txt\n")?; - assert!(error.to_string().contains("core.attributesFile"), "{error}"); - fs::remove_file(attributes)?; + assert_eq!(status.untracked_files().len(), 1); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Untracked); Ok(()) } #[test] - fn exact_recovery_rejects_filter_command_changed_after_preview() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - fs::write( - Git::new(repo.path()).git_path("info/attributes")?, - "conflict.txt filter=marker\n", + fn parses_renamed_file() -> Result<(), Box> { + let status = parse_status( + "# branch.head main\n2 R. N... 100644 100644 100644 abc def R100 new.txt\told.txt\n", )?; - repo.run(["config", "filter.marker.smudge", "cat"])?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - repo.run(["config", "filter.marker.smudge", "false"])?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected a changed filter command to invalidate the preview".into()); - }; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); + assert_eq!(status.entries[0].path, PathBuf::from("new.txt")); + assert_eq!( + status.entries[0].original_path, + Some(PathBuf::from("old.txt")) + ); Ok(()) } - #[cfg(unix)] #[test] - fn exact_recovery_fences_global_config_changed_after_final_capture() - -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - const CHILD: &str = "BITBYGIT_TEST_GLOBAL_CONFIG_FENCE"; - if env::var_os(CHILD).is_none() { - let support = TempRepo::new()?; - let config = support.path().join("global-config"); - let hooks = support.path().join("global-hooks"); - let marker = support.path().join("external-hook-ran"); - fs::create_dir(&hooks)?; - fs::write(&config, "")?; - let output = Command::new(env::current_exe()?) - .args([ - "--exact", - "tests::exact_recovery_fences_global_config_changed_after_final_capture", - "--nocapture", - ]) - .env(CHILD, "1") - .env("GIT_CONFIG_GLOBAL", &config) - .env("BITBYGIT_TEST_GLOBAL_HOOKS", &hooks) - .env("BITBYGIT_TEST_GLOBAL_HOOK_MARKER", &marker) - .output()?; - assert!( - output.status.success(), - "global-config fence child failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(!marker.exists()); - return Ok(()); - } - - let config = PathBuf::from(env::var_os("GIT_CONFIG_GLOBAL").ok_or("missing config")?); - let hooks = - PathBuf::from(env::var_os("BITBYGIT_TEST_GLOBAL_HOOKS").ok_or("missing hooks path")?); - let marker = PathBuf::from( - env::var_os("BITBYGIT_TEST_GLOBAL_HOOK_MARKER").ok_or("missing hook marker")?, - ); - let hook = hooks.join("reference-transaction"); - fs::write( - &hook, - format!( - "#!/bin/sh\ntouch {}\n", - shell_quote(&marker.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, - )? { - return Ok(()); - } - let changed_config = config.clone(); - let configured_hooks = hooks.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write( - changed_config, - format!("[core]\n\thooksPath = {}\n", configured_hooks.display()), - ); - }); - RECOVERY_EXECUTION_HOOKS - .lock() - .map_err(|_| "recovery execution hook lock poisoned")? - .push((repo.path().canonicalize()?, hook)); - - git.recover_exact( - RepositoryOperation::Merge, - RecoveryAction::Continue, - &expected, + fn parses_conflicted_file() -> Result<(), Box> { + let status = parse_status( + "# branch.head main\nu UU N... 100644 100644 100644 100644 one two three conflict.txt\n", )?; - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, None); + assert_eq!(status.conflicted_files().len(), 1); + assert_eq!(status.staged_files().len(), 0); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Conflict); Ok(()) } #[test] - fn recovery_rejects_local_config_including_external_storage() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let external = repo.path().with_extension("included-config"); - fs::write(&external, "[core]\n\thooksPath = hooks\n")?; - let config = repo.path().join(".git/config"); - let mut contents = fs::read_to_string(&config)?; - contents.push_str(&format!("\n[include]\n\tpath = {}\n", external.display())); - fs::write(config, contents)?; - - let Err(error) = Git::new(repo.path()).recovery_state() else { - return Err("external config include should be rejected".into()); - }; + fn parses_detached_head() -> Result<(), Box> { + let status = parse_status("# branch.oid abc123\n# branch.head (detached)\n")?; - assert!(error.to_string().contains("configuration included from")); - fs::remove_file(external)?; + assert_eq!(status.branch.head, Head::Detached("abc123".to_owned())); Ok(()) } - #[cfg(unix)] #[test] - fn exact_recovery_rejects_hook_content_changed_after_preview() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let hooks = repo.path().join("hooks"); - fs::create_dir(&hooks)?; - let marker = repo.path().with_extension("changed-hook-ran"); - let hook = hooks.join("reference-transaction"); - fs::write(&hook, "#!/bin/sh\nexit 0\n")?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - repo.run(["config", "core.hooksPath", "hooks"])?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - fs::write( - &hook, - format!( - "#!/bin/sh\ntouch {}\n", - shell_quote(&marker.to_string_lossy()) - ), - )?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected changed hook content to invalidate the preview".into()); - }; + fn parses_unborn_branch() -> Result<(), Box> { + let status = parse_status("# branch.oid (initial)\n# branch.head main\n")?; - assert!(error.to_string().contains("state changed after preview")); - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(status.branch.head, Head::Branch("main".to_owned())); + assert!(status.branch.unborn); Ok(()) } - #[cfg(unix)] #[test] - fn exact_recovery_revalidates_hook_content_after_copy_preparation() -> Result<(), Box> - { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let hook = repo.path().join(".git/hooks/reference-transaction"); - fs::write(&hook, "#!/bin/sh\nexit 0\n")?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let marker = repo.path().with_extension("late-hook-ran"); - let changed_hook = hook.clone(); - let changed_marker = marker.clone(); - let prepare_hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write( - changed_hook, - format!( - "#!/bin/sh\ntouch {}\n", - shell_quote(&changed_marker.to_string_lossy()) - ), - ); - }); - RECOVERY_PREPARE_HOOKS - .lock() - .map_err(|_| "recovery prepare hook lock poisoned")? - .push((repo.path().canonicalize()?, prepare_hook)); - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected hook change during copy preparation to block recovery".into()); - }; + fn parses_nul_delimited_raw_paths() -> Result<(), Box> { + let status = parse_status("# branch.head main\0? café.txt\0? tab\tname.txt\0")?; - assert!( - error - .to_string() - .contains("configuration, or hooks changed while isolated recovery was prepared"), - "{error}" - ); - assert!(!marker.exists()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(status.untracked_files().len(), 2); + assert_eq!(status.entries[0].path, PathBuf::from("café.txt")); + assert_eq!(status.entries[1].path, PathBuf::from("tab\tname.txt")); Ok(()) } #[test] - fn exact_recovery_rejects_changed_merge_metadata() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - fs::write(git.git_path("MERGE_MSG")?, "changed merge message\n")?; - - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected changed merge metadata to be rejected".into()); - }; + fn parses_nul_delimited_rename() -> Result<(), Box> { + let status = parse_status(concat!( + "# branch.head main\0", + "2 R. N... 100644 100644 100644 abc def R100 new\tname.txt\0", + "old name.txt\0" + ))?; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Renamed); + assert_eq!(status.entries[0].path, PathBuf::from("new\tname.txt")); + assert_eq!( + status.entries[0].original_path, + Some(PathBuf::from("old name.txt")) + ); Ok(()) } #[test] - fn exact_recovery_rejects_changed_rebase_metadata() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let todo = git.git_path("rebase-merge/git-rebase-todo")?; - let mut changed_todo = fs::read(&todo)?; - changed_todo.extend_from_slice(b"# changed after preview\n"); - fs::write(todo, changed_todo)?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected changed rebase metadata to be rejected".into()); - }; + fn parses_copied_file() -> Result<(), Box> { + let status = parse_status(concat!( + "# branch.head main\0", + "2 C. N... 100644 100644 100644 abc def C100 copy.txt\0", + "source.txt\0" + ))?; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.entries[0].entry_type, StatusEntryType::Copied); + assert_eq!(status.staged_files().len(), 1); Ok(()) } #[test] - fn exact_rebase_abort_preserves_branch_changed_after_preview() -> Result<(), Box> { - let (repo, original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); - assert_ne!(newer_head, original_head); - repo.run(["update-ref", "refs/heads/topic", &newer_head])?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected changed rebase branch to block abort".into()); - }; + fn parses_remotes() { + let remotes = parse_remotes( + "origin\thttps://github.com/cosentinode/bitbygit.git (fetch)\norigin\tgit@github.com:cosentinode/bitbygit.git (push)\n", + ); - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(remotes.len(), 1); + assert_eq!(remotes[0].name, "origin"); + assert_eq!( + remotes[0].fetch_url, + Some("https://github.com/cosentinode/bitbygit.git".to_owned()) + ); assert_eq!( - repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), - newer_head + remotes[0].push_url, + Some("git@github.com:cosentinode/bitbygit.git".to_owned()) ); + } + + #[test] + fn parses_branches_and_skips_remote_head() -> Result<(), Box> { + let branches = parse_branches( + "refs/heads/main\x001111111111111111111111111111111111111111\x00origin/main\x00*\nrefs/remotes/origin/main\x002222222222222222222222222222222222222222\x00\x00\nrefs/remotes/origin/HEAD\x002222222222222222222222222222222222222222\x00\x00\n", + )?; + + assert_eq!(branches.len(), 2); + assert_eq!(branches[0].name, "main"); + assert_eq!(branches[0].kind, BranchKind::Local); + assert!(branches[0].current); + assert_eq!(branches[0].upstream.as_deref(), Some("origin/main")); + assert_eq!(branches[1].name, "origin/main"); + assert_eq!(branches[1].kind, BranchKind::Remote); Ok(()) } #[test] - fn exact_rebase_continue_rejects_changed_update_refs_branch() -> Result<(), Box> { - let repo = initialized_repo()?; - repo.run(["switch", "-c", "topic"])?; - repo.write("first.txt", "first\n")?; - repo.run(["add", "first.txt"])?; - repo.run(["commit", "-m", "first"])?; - repo.run(["branch", "side"])?; - repo.write("second.txt", "second\n")?; - repo.run(["add", "second.txt"])?; - repo.run(["commit", "-m", "second"])?; - repo.run(["switch", "main"])?; - repo.write("upstream.txt", "upstream\n")?; - repo.run(["add", "upstream.txt"])?; - repo.run(["commit", "-m", "upstream"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--update-refs", "--exec", "false", "main"])?; + fn branch_target_blocks_ambiguous_local_and_remote_names() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["branch", "origin/main"])?; + repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; + let git = Git::new(repo.path()); + + let result = git.branch_target("origin/main"); - let git = Git::new(repo.path()); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - let update_refs = fs::read(git.git_path("rebase-merge/update-refs")?)?; - assert!( - update_refs - .split(|byte| *byte == b'\n') - .any(|line| line == b"refs/heads/side") - ); - let expected = git.recovery_state()?; - let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); - let moved_side = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); - repo.run(["update-ref", "refs/heads/side", &moved_side])?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Continue, - &expected, - ) else { - return Err("expected changed update-refs branch to block rebase continue".into()); + let Err(error) = result else { + return Err("expected ambiguous branch guardrail".into()); }; - - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(repo.git_stdout(["rev-parse", "HEAD"])?.trim(), preview_head); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - assert_eq!( - repo.git_stdout(["rev-parse", "refs/heads/side"])?.trim(), - moved_side - ); + assert!(error.to_string().contains("ambiguous")); Ok(()) } - #[cfg(unix)] #[test] - fn exact_rebase_abort_preserves_ignored_file_chmod_after_preview() -> Result<(), Box> - { - use std::os::unix::fs::PermissionsExt; - - let repo = prepare_rebase_with_ignored_victim()?; + fn reads_repository_state_from_temp_repo() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.write("README.md", "changed\n")?; + repo.write("staged.txt", "staged\n")?; + repo.run(["add", "staged.txt"])?; + repo.write("untracked.txt", "untracked\n")?; let git = Git::new(repo.path()); - let victim = repo.path().join("target/victim.bin"); - let expected = git.recovery_state()?; - let mut permissions = fs::metadata(&victim)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&victim, permissions)?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected ignored executable mode change to block rebase abort".into()); - }; + let state = git.repository()?; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - assert_ne!(fs::metadata(victim)?.permissions().mode() & 0o111, 0); + assert_eq!(state.root, repo.path()); + assert_eq!(state.branch.head, Head::Branch("main".to_owned())); + assert_eq!(state.status.staged_files().len(), 1); + assert_eq!(state.status.unstaged_files().len(), 1); + assert_eq!(state.status.untracked_files().len(), 1); Ok(()) } #[test] - fn exact_rebase_abort_preserves_ignored_directory_child_changed_after_preview() - -> Result<(), Box> { - let repo = prepare_rebase_with_ignored_directory_collision()?; + fn branch_workflows_create_checkout_merge_and_rebase() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; let git = Git::new(repo.path()); - let victim = repo.path().join("victim"); - let child = victim.join("data"); - let expected = git.recovery_state()?; - fs::write(&child, "changed after preview\n")?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected ignored directory child change to block rebase abort".into()); - }; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - assert!(victim.is_dir()); - assert_eq!(fs::read_to_string(child)?, "changed after preview\n"); - Ok(()) - } + git.create_branch("feature", None, &git.head_target()?)?; + repo.write("feature.txt", "feature\n")?; + repo.run(["add", "feature.txt"])?; + repo.run(["commit", "-m", "feature"])?; + let feature = git.branch_target("feature")?.ok_or("missing feature")?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.checkout_branch(&main, &git.head_target()?)?; + git.merge_ff_only(&feature, &git.head_target()?)?; + assert!(repo.path().join("feature.txt").exists()); - #[test] - fn exact_rebase_uses_backend_orig_head_when_top_level_orig_head_is_wrong() - -> Result<(), Box> { - let repo = prepare_rebase_with_ignored_victim()?; - let git = Git::new(repo.path()); - let victim = repo.path().join("target/victim.bin"); - repo.run(["update-ref", "ORIG_HEAD", "HEAD"])?; - assert_ne!( - fs::read_to_string(git.git_path("rebase-merge/orig-head")?)?.trim(), - repo.git_stdout(["rev-parse", "ORIG_HEAD"])?.trim() - ); - let expected = git.recovery_state()?; - fs::write(&victim, "changed after preview\n")?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected ignored content change to block rebase abort".into()); - }; + git.create_branch("topic", None, &git.head_target()?)?; + repo.write("topic.txt", "topic\n")?; + repo.run(["add", "topic.txt"])?; + repo.run(["commit", "-m", "topic"])?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.checkout_branch(&main, &git.head_target()?)?; + repo.write("base.txt", "base\n")?; + repo.run(["add", "base.txt"])?; + repo.run(["commit", "-m", "base"])?; + let topic = git.branch_target("topic")?.ok_or("missing topic")?; + git.checkout_branch(&topic, &git.head_target()?)?; + let main = git.branch_target("main")?.ok_or("missing main")?; + git.rebase_onto(&main, &git.head_target()?)?; - assert!(error.to_string().contains("state changed after preview")); - assert_eq!(fs::read_to_string(victim)?, "changed after preview\n"); + repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; Ok(()) } #[test] - fn recovery_metadata_entry_bound_applies_before_wide_tree_traversal() - -> Result<(), Box> { - let repo = initialized_repo()?; + fn branch_workflows_block_dirty_tree() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.write("README.md", "dirty\n")?; let git = Git::new(repo.path()); - let backend = git.git_path("rebase-merge")?; - fs::create_dir(&backend)?; - for index in 0..MAX_RECOVERY_METADATA_ENTRIES { - fs::write(backend.join(index.to_string()), [])?; - } - let Err(error) = git.recovery_state() else { - return Err("expected wide recovery metadata tree to exceed entry bound".into()); + let result = git.create_branch("feature", None, &git.head_target()?); + + let Err(error) = result else { + return Err("expected dirty tree guardrail".into()); }; - assert!(error.to_string().contains("metadata entries")); + assert!(error.to_string().contains("working tree is not clean")); Ok(()) } #[test] - fn recovery_generation_collection_rejects_an_over_limit_wide_directory() - -> Result<(), Box> { + fn remote_checkout_blocks_when_local_branch_exists() -> Result<(), Box> { let repo = TempRepo::new()?; - let wide = repo.path().join("wide"); - fs::create_dir(&wide)?; - for name in ["a", "b", "c"] { - fs::write(wide.join(name), [])?; - } - let mut path_bytes = 0; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["update-ref", "refs/remotes/origin/main", "HEAD"])?; + let git = Git::new(repo.path()); + let remote = git.branch_target("origin/main")?.ok_or("missing remote")?; - let Err(error) = - read_recovery_generation_children(&wide, Path::new("wide"), 0, &mut path_bytes, 2) - else { - return Err("expected wide generation directory to exceed entry bound".into()); - }; + let result = git.checkout_branch(&remote, &git.head_target()?); - assert!(error.to_string().contains("entry or path bounds")); - assert!(path_bytes <= "wide/a".len() + "wide/b".len()); + let Err(error) = result else { + return Err("expected remote checkout guardrail".into()); + }; + assert!( + error + .to_string() + .contains("local branch main already exists") + ); Ok(()) } - #[cfg(target_os = "linux")] #[test] - fn recovery_cleanup_collection_rejects_an_over_limit_wide_directory() - -> Result<(), Box> { + fn branch_workflows_block_in_progress_rebase() -> Result<(), Box> { let repo = TempRepo::new()?; - let wide = repo.path().join("wide"); - fs::create_dir(&wide)?; - for name in ["a", "b", "c"] { - fs::write(wide.join(name), [])?; - } - let directory = fs::File::open(&wide)?; - let mut entries = 0; - let mut path_bytes = 0; - - let Err(error) = read_recovery_cleanup_names(&directory, &mut entries, &mut path_bytes, 2) - else { - return Err("expected wide cleanup directory to exceed entry bound".into()); - }; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("README.md", "initial\n")?; + repo.run(["add", "README.md"])?; + repo.run(["commit", "-m", "initial"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("topic.txt", "topic\n")?; + repo.run(["add", "topic.txt"])?; + repo.run(["commit", "-m", "topic"])?; + repo.run(["switch", "main"])?; + repo.write("base.txt", "base\n")?; + repo.run(["add", "base.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + let git = Git::new(repo.path()); - assert!(error.to_string().contains("entry or path bounds")); - assert_eq!(entries, 2); - assert_eq!(path_bytes, 2); - Ok(()) - } + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - #[test] - fn changed_path_parser_stops_at_relevant_file_bound() -> Result<(), Box> { - let mut output = Vec::new(); - for index in 0..=MAX_RECOVERY_RELEVANT_FILES { - output.extend_from_slice(b":100644 100644 0000000 0000000 M\0"); - output.extend_from_slice(format!("path-{index}\0").as_bytes()); - } - let mut paths = BTreeSet::new(); + let result = git.create_branch("new-topic", None, &git.head_target()?); - let Err(error) = parse_recovery_changed_paths(&output, &mut paths) else { - return Err("expected changed paths to exceed relevant file bound".into()); + let Err(error) = result else { + return Err("expected in-progress rebase guardrail".into()); }; - assert!(error.to_string().contains("relevant file count")); - assert_eq!(paths.len(), MAX_RECOVERY_RELEVANT_FILES); + assert!( + error + .to_string() + .contains("rebase operation is in progress") + ); Ok(()) } #[test] - fn exact_recovery_ignores_and_preserves_unrelated_untracked_tree() -> Result<(), Box> - { - let (repo, _original_head) = prepare_merge_conflict()?; - fs::create_dir(repo.path().join("ignored"))?; - fs::write(repo.path().join(".git/info/exclude"), "ignored/\n")?; - for index in 0..=MAX_RECOVERY_RELEVANT_FILES { - repo.write(&format!("ignored/{index}"), "ignored\n")?; - } - repo.write("notes.txt", "before preview\n")?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - repo.write("notes.txt", "changed after preview\n")?; - - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } + fn detects_conflicts_in_temp_repo() -> Result<(), Box> { + let repo = TempRepo::new()?; + repo.run(["init", "-b", "main"])?; + repo.run(["config", "user.email", "bitbygit@example.invalid"])?; + repo.run(["config", "user.name", "bitbygit test"])?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["checkout", "-b", "other"])?; + repo.write("conflict.txt", "other\n")?; + repo.run(["commit", "-am", "other"])?; + repo.run(["checkout", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run_allow_failure(["merge", "other"])?; - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + let status = Git::new(repo.path()).status()?; - assert_eq!(git.status()?.operation, None); + assert_eq!(status.operation, Some(RepositoryOperation::Merge)); + assert_eq!(status.conflicted_files().len(), 1); assert_eq!( - fs::read_to_string(repo.path().join("notes.txt"))?, - "changed after preview\n" + status.conflicted_files()[0].path, + PathBuf::from("conflict.txt") ); - Ok(()) - } - #[test] - fn recovery_state_blocks_large_tracked_binary_before_reading_it() -> Result<(), Box> - { - use std::io::Write; - - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let mut file = fs::File::options() - .write(true) - .truncate(true) - .open(repo.path().join("conflict.txt"))?; - let mut chunk = [0_u8; 64 * 1024]; - let mut value = 0x9e37_79b9_u32; - for byte in &mut chunk { - value ^= value << 13; - value ^= value >> 17; - value ^= value << 5; - *byte = value as u8; - } - for _ in 0..(MAX_RECOVERY_FILE_BYTES_READ / chunk.len() as u64) { - file.write_all(&chunk)?; - } - file.write_all(&[1])?; - file.flush()?; + repo.run(["add", "conflict.txt"])?; + let status = Git::new(repo.path()).status()?; - let Err(error) = git.recovery_state() else { - return Err("expected large tracked file to exceed recovery read bound".into()); - }; - assert!(error.to_string().contains("file content bytes read")); + assert_eq!(status.operation, Some(RepositoryOperation::Merge)); + assert!(status.conflicted_files().is_empty()); Ok(()) } #[test] - fn exact_recovery_blocks_relevant_filesystem_type_replacement() -> Result<(), Box> { + fn merge_continue_requires_resolution_and_finishes_without_an_editor() + -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; + repo.run(["config", "core.editor", "false"])?; let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let conflict = repo.path().join("conflict.txt"); - fs::remove_file(&conflict)?; - fs::create_dir(&conflict)?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) - else { - return Err("expected filesystem type replacement to block recovery".into()); + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected unresolved merge to block continue".into()); }; - assert!(error.to_string().contains("state changed after preview")); - assert!(conflict.is_dir()); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn recovery_fingerprint_does_not_hang_on_fifo_replacement() -> Result<(), Box> { - use std::os::unix::fs::FileTypeExt; + assert!(error.to_string().contains("unresolved conflicts")); - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let conflict = repo.path().join("conflict.txt"); - let fifo = repo.path().join("replacement-fifo"); - let status = Command::new("mkfifo").arg(&fifo).status()?; - if !status.success() { - return Err("mkfifo failed".into()); - } - let replacement = fifo.clone(); - let destination = conflict.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::rename(replacement, destination); - }); - RECOVERY_FILE_OPEN_HOOKS - .lock() - .map_err(|_| "recovery file-open hook lock poisoned")? - .push((conflict.clone(), hook)); + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; - let Err(error) = git.recovery_state() else { - return Err("expected FIFO replacement to block recovery guard".into()); - }; - assert!(matches!(error, GitError::Blocked { .. })); - assert!(fs::symlink_metadata(conflict)?.file_type().is_fifo()); + assert_eq!(git.status()?.operation, None); + repo.run(["rev-parse", "--verify", "HEAD^2"])?; Ok(()) } - #[cfg(unix)] #[test] - fn recovery_fingerprint_does_not_follow_symlink_replacement() -> Result<(), Box> { - use std::os::unix::fs::symlink; - - let (repo, _original_head) = prepare_merge_conflict()?; + fn merge_abort_clears_operation_and_restores_head() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); - let conflict = repo.path().join("conflict.txt"); - let secret = repo.path().join("secret.txt"); - let replacement = repo.path().join("replacement-link"); - fs::write(&secret, "must not be fingerprinted\n")?; - symlink(&secret, &replacement)?; - let destination = conflict.clone(); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::rename(replacement, destination); - }); - RECOVERY_FILE_OPEN_HOOKS - .lock() - .map_err(|_| "recovery file-open hook lock poisoned")? - .push((conflict.clone(), hook)); - let Err(error) = git.recovery_state() else { - return Err("expected symlink replacement to block recovery guard".into()); - }; - assert!(matches!(error, GitError::Blocked { .. })); - assert!(fs::symlink_metadata(conflict)?.file_type().is_symlink()); - assert_eq!(fs::read_to_string(secret)?, "must not be fingerprinted\n"); + git.recover(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "main\n" + ); Ok(()) } #[test] - fn exact_recovery_repeats_guard_after_synchronized_concurrent_edit() - -> Result<(), Box> { - use std::sync::mpsc; - - let (repo, _original_head) = prepare_merge_conflict()?; + fn rebase_continue_reports_the_next_conflict_then_finishes() -> Result<(), Box> { + let repo = prepare_two_conflict_rebase()?; + repo.run(["config", "core.editor", "false"])?; let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let conflict = repo.path().join("conflict.txt"); - let (scanned_tx, scanned_rx) = mpsc::channel(); - let (edited_tx, edited_rx) = mpsc::channel(); - let editor = std::thread::spawn(move || { - let _ = scanned_rx.recv(); - let result = fs::write(conflict, "concurrent edit\n"); - let _ = edited_tx.send(result); - }); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = scanned_tx.send(()); - let _ = edited_rx.recv(); - }); - *RECOVERY_CAPTURE_HOOK - .lock() - .map_err(|_| "recovery capture hook lock poisoned")? = Some((repo.path(), hook)); - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + repo.write("first.txt", "topic first\n")?; + repo.run(["add", "first.txt"])?; + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected rebase to stop at the next conflict".into()); + }; + let GitError::GitFailed { + args, + stdout, + stderr, + .. + } = &error else { - return Err("expected concurrent edit to block recovery".into()); + return Err(format!("expected git failure for next conflict, got {error}").into()); }; - editor.join().map_err(|_| "editor thread panicked")?; - let message = error.to_string(); - assert!(message.contains("changed"), "{message}"); + assert_eq!(args, &["rebase".to_owned(), "--continue".to_owned()]); + assert!(format!("{stdout}\n{stderr}").contains("second.txt")); + let status = git.status()?; + assert_eq!(status.operation, Some(RepositoryOperation::Rebase)); + assert_eq!(status.conflicted_files().len(), 1); assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "concurrent edit\n" + status.conflicted_files()[0].path, + PathBuf::from("second.txt") ); + + repo.write("second.txt", "topic second\n")?; + repo.run(["add", "second.txt"])?; + git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + + assert_eq!(git.status()?.operation, None); + repo.run(["merge-base", "--is-ancestor", "main", "HEAD"])?; Ok(()) } - #[cfg(unix)] #[test] - fn isolated_rebase_abort_preserves_ref_race_during_prepared_hook_without_partial_recovery() - -> Result<(), Box> { + fn rebase_abort_clears_operation_and_restores_head() -> Result<(), Box> { let (repo, original_head) = prepare_rebase_conflict()?; let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let (signal, release) = install_recovery_prepared_barrier(&repo)?; - let expected = git.recovery_state()?; - let preview_head = repo.git_stdout(["rev-parse", "HEAD"])?; - let preview_conflict = fs::read(repo.path().join("conflict.txt"))?; - let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); - assert_ne!(newer_head, original_head); - let worker_path = repo.path(); - let worker_state = expected.clone(); - let worker = std::thread::spawn(move || { - Git::new(worker_path).recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &worker_state, - ) - }); - wait_for_recovery_barrier(&signal)?; - fs::write(git.git_path("refs/heads/topic")?, format!("{newer_head}\n"))?; - fs::write(&release, [])?; - let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { - return Err("expected exact rebase abort to fail closed".into()); - }; - assert!( - error - .to_string() - .contains("while recovery executed in isolation"), - "{error}" - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - assert_eq!(repo.git_stdout(["rev-parse", "HEAD"])?, preview_head); - let current = git.recovery_state()?; - assert_eq!(current.index, expected.index); - assert_eq!(current.worktree, expected.worktree); - assert_eq!(current.metadata, expected.metadata); + git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + + assert_eq!(git.status()?.operation, None); assert_eq!( - fs::read(repo.path().join("conflict.txt"))?, - preview_conflict + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head ); assert_eq!( - repo.git_stdout(["rev-parse", "refs/heads/topic"])?.trim(), - newer_head + fs::read_to_string(repo.path().join("conflict.txt"))?, + "topic\n" ); - let _ = fs::remove_file(signal); - let _ = fs::remove_file(release); Ok(()) } - #[cfg(unix)] #[test] - fn isolated_recovery_preserves_post_spawn_worktree_race() -> Result<(), Box> { + fn rebase_skip_drops_the_conflicting_commit() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let (signal, release) = install_recovery_prepared_barrier(&repo)?; - let expected = git.recovery_state()?; - let worker_path = repo.path(); - let worker_state = expected.clone(); - let worker = std::thread::spawn(move || { - Git::new(worker_path).recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &worker_state, - ) - }); - wait_for_recovery_barrier(&signal)?; - fs::write( - repo.path().join("conflict.txt"), - "post-spawn worktree edit\n", - )?; - fs::write(&release, [])?; - let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { - return Err("expected late worktree edit to block recovery".into()); - }; + git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; - assert!( - error - .to_string() - .contains("while recovery executed in isolation") - ); - let current = git.recovery_state()?; - assert_eq!(current.head, expected.head); - assert_eq!(current.index, expected.index); - assert_eq!(current.metadata, expected.metadata); - assert_eq!(current.refs, expected.refs); + assert_eq!(git.status()?.operation, None); assert_eq!( fs::read_to_string(repo.path().join("conflict.txt"))?, - "post-spawn worktree edit\n" + "main\n" ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - let _ = fs::remove_file(signal); - let _ = fs::remove_file(release); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn isolated_recovery_preserves_post_spawn_index_race() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let (signal, release) = install_recovery_prepared_barrier(&repo)?; - let expected = git.recovery_state()?; - let original_blob = repo - .git_stdout(["rev-parse", "HEAD:conflict.txt"])? - .trim() - .to_owned(); - let worker_path = repo.path(); - let worker_state = expected.clone(); - let worker = std::thread::spawn(move || { - Git::new(worker_path).recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &worker_state, - ) - }); - wait_for_recovery_barrier(&signal)?; - let output = Command::new("git") - .current_dir(repo.path()) - .args([ - "update-index", - "--cacheinfo", - "100644", - &original_blob, - "conflict.txt", - ]) - .output()?; - assert!(output.status.success()); - fs::write(&release, [])?; - - let Err(error) = worker.join().map_err(|_| "recovery worker panicked")? else { - return Err("expected late index edit to block recovery".into()); - }; - - assert!( - error - .to_string() - .contains("while recovery executed in isolation") + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + repo.git_stdout(["rev-parse", "main"])?.trim() ); - let current = git.recovery_state()?; - assert_eq!(current.head, expected.head); - assert_ne!(current.index, expected.index); - assert_eq!(current.worktree, expected.worktree); - assert_eq!(current.metadata, expected.metadata); - assert_eq!(current.refs, expected.refs); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); - let _ = fs::remove_file(signal); - let _ = fs::remove_file(release); Ok(()) } #[test] - fn atomic_promotion_rolls_back_race_after_expected_value_check() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let conflict = repo.path().join("conflict.txt"); - let hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write(conflict, "promotion race\n"); - }); - RECOVERY_PROMOTION_HOOKS - .lock() - .map_err(|_| "recovery promotion hook lock poisoned")? - .push((repo.path().canonicalize()?, hook)); + fn rebase_merge_step_recovery_preserves_rebase_identity() -> Result<(), Box> { + let (abort_repo, original_head) = prepare_rebase_merge_conflict()?; + let abort_git = Git::new(abort_repo.path()); - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + let Err(error) = abort_git.recover(RepositoryOperation::Merge, RecoveryAction::Abort) else { - return Err("expected promotion race to roll back recovery".into()); + return Err("expected nested merge recovery to be rejected".into()); }; + assert!(error.to_string().contains("rebase operation is active")); + assert!(abort_git.git_path("rebase-merge")?.exists()); + assert!(abort_git.git_path("MERGE_HEAD")?.exists()); - assert!( - error - .to_string() - .contains("during atomic recovery promotion") - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - let current = git.recovery_state()?; - assert_eq!(current.head, expected.head); - assert_eq!(current.index, expected.index); - assert_eq!(current.metadata, expected.metadata); - assert_eq!(current.refs, expected.refs); + abort_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + assert_eq!(abort_git.status()?.operation, None); assert_eq!( - fs::read_to_string(repo.path().join("conflict.txt"))?, - "promotion race\n" + abort_repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head ); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn atomic_promotion_rollback_retains_post_exchange_write() -> Result<(), Box> { - let (repo, _original_head) = prepare_merge_conflict()?; - let mut stale_file = fs::OpenOptions::new() - .write(true) - .open(repo.path().join("conflict.txt"))?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Abort, - &expected, - )? { - return Ok(()); - } - let root = repo.path().canonicalize()?; - let written = root.join("post-exchange-write"); - let hook_written = written.clone(); - let stale_hook: RecoveryCaptureHook = Box::new(move || { - let _ = stale_file.set_len(0); - let _ = stale_file.write_all(b"force rollback after exchange\n"); - let _ = stale_file.sync_all(); - }); - RECOVERY_POST_EXCHANGE_HOOKS - .lock() - .map_err(|_| "post-exchange hook lock poisoned")? - .push((root.clone(), stale_hook)); - let installed_hook: RecoveryCaptureHook = Box::new(move || { - let _ = fs::write(hook_written, "must be retained\n"); - }); - RECOVERY_INSTALLED_CAPTURE_HOOKS - .lock() - .map_err(|_| "installed-capture hook lock poisoned")? - .push((root.clone(), installed_hook)); + let (continue_repo, _original_head) = prepare_rebase_merge_conflict()?; + let continue_git = Git::new(continue_repo.path()); let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { - return Err("expected post-exchange write to roll back promotion".into()); + return Err("expected unresolved nested merge to block continue".into()); }; + assert!(error.to_string().contains("unresolved conflicts")); + continue_repo.write("conflict.txt", "resolved again\n")?; + continue_repo.run(["add", "conflict.txt"])?; + continue_git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + assert_eq!(continue_git.status()?.operation, None); - assert!( - error - .to_string() - .contains("rolled-back generation was retained") - ); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); - assert!(!written.exists()); - let parent = root.parent().ok_or("test repository has no parent")?; - let retained = fs::read_dir(parent)? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .find(|path| { - path.file_name().is_some_and(|name| { - name.as_encoded_bytes() - .starts_with(RECOVERY_CANDIDATE_PREFIX.as_bytes()) - }) && path.join("post-exchange-write").is_file() - }) - .ok_or("post-exchange generation was not retained")?; - assert_eq!( - fs::read_to_string(retained.join("post-exchange-write"))?, - "must be retained\n" - ); - fs::remove_dir_all(&retained)?; - let _ = fs::remove_file(recovery_candidate_owner_path(&retained)); + let (skip_repo, _original_head) = prepare_rebase_merge_conflict()?; + let skip_git = Git::new(skip_repo.path()); + skip_git.recover(RepositoryOperation::Rebase, RecoveryAction::Skip)?; + assert_eq!(skip_git.status()?.operation, None); Ok(()) } - #[cfg(unix)] #[test] - fn recovery_leaves_configured_hook_namespace_and_config_unchanged() -> Result<(), Box> + fn recovery_rejects_merge_skip_absent_state_and_mismatched_state() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let hooks = repo.path().join("hooks"); - fs::create_dir(&hooks)?; - let support_files = [ - ("expected-refs", b"configured expected refs\n".as_slice()), - ( - "transaction-input", - b"configured transaction input\n".as_slice(), - ), - ]; - for (name, contents) in support_files { - fs::write(hooks.join(name), contents)?; - } - let commit_hook = hooks.join("commit-msg"); - fs::write( - &commit_hook, - "#!/bin/sh\npwd > configured-commit-hook-ran\n", - )?; - let transaction_hook = hooks.join("reference-transaction"); - fs::write( - &transaction_hook, - "#!/bin/sh\nprintf '%s\\n' \"$1\" >> configured-reference-hook-ran\n", - )?; - for hook in [&commit_hook, &transaction_hook] { - let mut permissions = fs::metadata(hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(hook, permissions)?; - } - repo.run(["config", "core.hooksPath", "hooks"])?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let git = Git::new(repo.path()); - let state = git.recovery_state()?; - let config_path = git.git_path("config")?; - let config_before = fs::read(&config_path)?; - let hook_files = [ - hooks.join("expected-refs"), - hooks.join("transaction-input"), - commit_hook, - transaction_hook, - ]; - let contents_before = hook_files - .iter() - .map(fs::read) - .collect::, _>>()?; - - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &state, - )? { - return Ok(()); + let clean = initialized_repo()?; + let clean_git = Git::new(clean.path()); + for (operation, action) in [ + (RepositoryOperation::Merge, RecoveryAction::Continue), + (RepositoryOperation::Merge, RecoveryAction::Abort), + (RepositoryOperation::Rebase, RecoveryAction::Continue), + (RepositoryOperation::Rebase, RecoveryAction::Abort), + (RepositoryOperation::Rebase, RecoveryAction::Skip), + ] { + let Err(error) = clean_git.recover(operation, action) else { + return Err( + format!("expected {operation:?} {action:?} to require active state").into(), + ); + }; + assert!(error.to_string().contains("no")); + assert!(error.to_string().contains("operation is active")); } - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state)?; - - assert_eq!(git.status()?.operation, None); + let (merge_repo, _original_head) = prepare_merge_conflict()?; + let merge_git = Git::new(merge_repo.path()); + let Err(error) = merge_git.recover(RepositoryOperation::Merge, RecoveryAction::Skip) else { + return Err("expected merge skip to be rejected".into()); + }; + assert!(error.to_string().contains("does not support")); assert_eq!( - fs::read_to_string(repo.path().join("configured-commit-hook-ran"))?.trim(), - repo.path().canonicalize()?.to_string_lossy() + merge_git.status()?.operation, + Some(RepositoryOperation::Merge) ); - assert!(repo.path().join("configured-reference-hook-ran").exists()); - assert_eq!(fs::read(config_path)?, config_before); - for (path, contents) in hook_files.iter().zip(contents_before) { - assert_eq!(fs::read(path)?, contents); - } - assert!(fs::read_dir(git.git_path("")?)?.all(|entry| { - entry.is_ok_and(|entry| { - !entry - .file_name() - .to_string_lossy() - .starts_with("bitbygit-recovery-hooks-") - }) - })); - Ok(()) - } - #[test] - fn exact_rebase_rejects_changed_rewritten_ref() -> Result<(), Box> { - let (repo, _original_head) = prepare_rebase_merge_conflict()?; - let git = Git::new(repo.path()); - let expected = git.recovery_state()?; - let newer_head = repo.git_stdout(["rev-parse", "main"])?.trim().to_owned(); - repo.run(["update-ref", "refs/rewritten/concurrent", &newer_head])?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected changed rewritten ref to block recovery".into()); + let Err(error) = merge_git.recover(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected mismatched rebase recovery to be rejected".into()); }; - - assert!(error.to_string().contains("state changed after preview")); + assert!(error.to_string().contains("merge operation is active")); assert_eq!( - repo.git_stdout(["rev-parse", "refs/rewritten/concurrent"])? - .trim(), - newer_head + merge_git.status()?.operation, + Some(RepositoryOperation::Merge) ); Ok(()) } @@ -9395,17 +2542,12 @@ mod tests { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; - let hook_marker = repo.path().with_extension("hook-ran"); - let signing_marker = repo.path().with_extension("signing-ran"); let hooks = repo.path().join("hooks"); fs::create_dir(&hooks)?; let commit_msg_hook = hooks.join("commit-msg"); fs::write( &commit_msg_hook, - format!( - "#!/bin/sh\ntouch {}\nprintf '\\377'\n", - shell_quote(&hook_marker.to_string_lossy()) - ), + "#!/bin/sh\ntouch hook-ran\nprintf '\\377'\n", )?; let mut permissions = fs::metadata(&commit_msg_hook)?.permissions(); permissions.set_mode(0o755); @@ -9413,13 +2555,7 @@ mod tests { repo.run(["config", "core.hooksPath", "hooks"])?; let signing_program = repo.path().join("signing-program"); - fs::write( - &signing_program, - format!( - "#!/bin/sh\ntouch {}\nexit 1\n", - shell_quote(&signing_marker.to_string_lossy()) - ), - )?; + fs::write(&signing_program, "#!/bin/sh\ntouch signing-ran\nexit 1\n")?; let mut permissions = fs::metadata(&signing_program)?.permissions(); permissions.set_mode(0o755); fs::set_permissions(&signing_program, permissions)?; @@ -9429,18 +2565,7 @@ mod tests { repo.run(["add", "conflict.txt"])?; let git = Git::new(repo.path()); - let state = git.recovery_state()?; - if !recovery_capabilities_or_verify_fail_closed( - &git, - RepositoryOperation::Merge, - RecoveryAction::Continue, - &state, - )? { - return Ok(()); - } - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Continue, &state) - else { + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { return Err("expected configured signing failure".into()); }; let GitError::GitFailed { @@ -9452,10 +2577,10 @@ mod tests { else { return Err("expected standard git merge failure".into()); }; - assert!(args.ends_with(&["merge".to_owned(), "--continue".to_owned()])); + assert_eq!(args, ["merge".to_owned(), "--continue".to_owned()]); assert!(format!("{stdout}{stderr}").contains('\u{fffd}')); - assert!(hook_marker.exists()); - assert!(signing_marker.exists()); + assert!(repo.path().join("hook-ran").exists()); + assert!(repo.path().join("signing-ran").exists()); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -10324,61 +3449,6 @@ mod tests { Ok((repo, original_head)) } - fn prepare_rebase_with_ignored_victim() -> Result> { - let repo = initialized_repo()?; - repo.write(".gitignore", "target/\n")?; - repo.run(["add", ".gitignore"])?; - repo.run(["commit", "-m", "ignore target"])?; - repo.run(["switch", "-c", "topic"])?; - repo.write("first.txt", "first\n")?; - repo.run(["add", "first.txt"])?; - repo.run(["commit", "-m", "first"])?; - repo.write(".gitignore", "")?; - fs::create_dir(repo.path().join("target"))?; - repo.write("target/victim.bin", "committed\n")?; - repo.run(["add", ".gitignore", "target/victim.bin"])?; - repo.run(["commit", "-m", "track victim"])?; - repo.run(["switch", "main"])?; - repo.write("upstream.txt", "upstream\n")?; - repo.run(["add", "upstream.txt"])?; - repo.run(["commit", "-m", "upstream"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; - fs::create_dir(repo.path().join("target"))?; - repo.write("target/victim.bin", "before preview\n")?; - assert_eq!( - Git::new(repo.path()).status()?.operation, - Some(RepositoryOperation::Rebase) - ); - Ok(repo) - } - - fn prepare_rebase_with_ignored_directory_collision() -> Result> { - let repo = initialized_repo()?; - repo.run(["switch", "-c", "topic"])?; - repo.write(".gitignore", "victim/\n")?; - repo.write("first.txt", "first\n")?; - repo.run(["add", ".gitignore", "first.txt"])?; - repo.run(["commit", "-m", "ignore victim"])?; - repo.write(".gitignore", "")?; - repo.write("victim", "committed file\n")?; - repo.run(["add", ".gitignore", "victim"])?; - repo.run(["commit", "-m", "track victim file"])?; - repo.run(["switch", "main"])?; - repo.write("upstream.txt", "upstream\n")?; - repo.run(["add", "upstream.txt"])?; - repo.run(["commit", "-m", "upstream"])?; - repo.run(["switch", "topic"])?; - repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; - fs::create_dir(repo.path().join("victim"))?; - repo.write("victim/data", "before preview\n")?; - assert_eq!( - Git::new(repo.path()).status()?.operation, - Some(RepositoryOperation::Rebase) - ); - Ok(repo) - } - fn prepare_two_conflict_rebase() -> Result> { let repo = initialized_repo()?; repo.write("first.txt", "base first\n")?; @@ -10433,48 +3503,6 @@ mod tests { Ok((repo, original_head)) } - #[cfg(unix)] - fn install_recovery_prepared_barrier( - repo: &TempRepo, - ) -> Result<(PathBuf, PathBuf), Box> { - use std::os::unix::fs::PermissionsExt; - - let repo_path = repo.path(); - let parent = repo_path.parent().ok_or("test repository has no parent")?; - let name = repo_path - .file_name() - .ok_or("test repository has no file name")? - .to_string_lossy(); - let signal = parent.join(format!("{name}-recovery-child-prepared")); - let release = parent.join(format!("{name}-recovery-child-release")); - let _ = fs::remove_file(&signal); - let _ = fs::remove_file(&release); - let hook = repo.path().join(".git/hooks/reference-transaction"); - fs::write( - &hook, - format!( - "#!/bin/sh\nif [ \"$1\" = prepared ]; then\n touch {}\n while [ ! -e {} ]; do sleep 0.01; done\nfi\n", - shell_quote(&signal.to_string_lossy()), - shell_quote(&release.to_string_lossy()) - ), - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(hook, permissions)?; - Ok((signal, release)) - } - - #[cfg(unix)] - fn wait_for_recovery_barrier(signal: &Path) -> Result<(), Box> { - for _ in 0..500 { - if signal.exists() { - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err("isolated recovery child did not reach its prepared hook".into()) - } - struct TempRepo { path: PathBuf, } @@ -10590,15 +3618,6 @@ mod tests { impl Drop for TempRepo { fn drop(&mut self) { - if let Ok(root) = self.path.canonicalize() - && let Ok(Some(backup)) = recovery_backup_from_pointer(&root) - { - let _result = fs::remove_dir_all(&backup); - let _result = fs::remove_file(recovery_candidate_owner_path(&backup)); - if let Ok(pointer) = recovery_backup_pointer_path(&root) { - let _result = fs::remove_file(pointer); - } - } let _result = fs::remove_dir_all(&self.path); } } diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 95eab7a..c82ad30 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -442,18 +442,7 @@ impl App { } fn execute_operation(&mut self, plan: OperationPlan, context: ExecutionContext) { - let recovery_cwd = matches!(&plan.request, OperationRequest::Recover(_)).then(|| { - let cwd = current_dir(); - Git::new(&cwd).repo_root().unwrap_or(cwd) - }); - let mut message = execute_typed_plan(&plan, context, self.policy.clone()); - if let Some(cwd) = recovery_cwd - && let Err(error) = std::env::set_current_dir(cwd) - { - message.push_str(&format!( - "\nUnable to reattach the TUI to the recovered repository: {error}" - )); - } + let message = execute_typed_plan(&plan, context, self.policy.clone()); if should_refresh_status_after(&plan) { self.refresh_status(); } @@ -476,25 +465,12 @@ impl App { sequence: QueuedPromptSequence, confirmed_requirement: ConfirmationRequirement, ) { - let has_recovery = std::iter::once(&sequence.first.plan.request) - .chain(sequence.remaining_requests.iter()) - .any(|request| matches!(request, OperationRequest::Recover(_))); - let recovery_cwd = has_recovery.then(|| { - let cwd = current_dir(); - Git::new(&cwd).repo_root().unwrap_or(cwd) - }); let result = PromptSequenceExecutor::current(self.policy.clone()) .execute_confirmed(sequence, confirmed_requirement); - let reattach_error = recovery_cwd.and_then(|cwd| std::env::set_current_dir(cwd).err()); if result.should_refresh_status() { self.refresh_status(); } self.details = result.message(); - if let Some(error) = reattach_error { - self.details.push_str(&format!( - "\nUnable to reattach the TUI to the recovered repository: {error}" - )); - } } fn submit_prompt(&mut self) { @@ -4914,24 +4890,6 @@ mod tests { use crossterm::event::{KeyModifiers, MouseEvent}; use ratatui::backend::TestBackend; - fn recovery_capabilities_available(repo: &std::path::Path) -> Result> { - match Git::new(repo).ensure_recovery_supported() { - Ok(()) => Ok(true), - Err(GitError::Blocked { message }) - if message.contains("required platform capabilities are missing") => - { - if std::env::var_os("BITBYGIT_REQUIRE_RECOVERY_SUCCESS").is_some() { - return Err(format!( - "production recovery capabilities are required for this test: {message}" - ) - .into()); - } - Ok(false) - } - Err(error) => Err(error.into()), - } - } - #[test] fn tab_cycles_visible_focus() { let mut app = App::new(); @@ -5602,6 +5560,28 @@ mod tests { } } + #[test] + fn recovery_rejects_lowercase_confirmation_without_side_effects() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-uppercase-confirmation")?; + let operation = OperationPlanner::new(&repo) + .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) + .map_err(std::io::Error::other)?; + let mut app = App::new(); + app.operation_queue + .enqueue(QueuedOperation::new(operation.plan, operation.context)); + + app.handle_key(key(KeyCode::Char('y'))); + + assert!(app.operation_queue.has_pending()); + assert!(app.details.contains("uppercase Y")); + assert_eq!( + Git::new(repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + Ok(()) + } + #[test] fn conflict_mode_blocks_risky_requests_before_sequence_side_effects() -> Result<(), Box> { @@ -5628,6 +5608,33 @@ mod tests { .plan_request(OperationRequest::Push) .is_err_and(|error| error.contains("active merge")) ); + for request in [ + OperationRequest::Commit { + message: "blocked".to_owned(), + }, + OperationRequest::Pull { rebase: false }, + OperationRequest::Pull { rebase: true }, + OperationRequest::Checkout { + branch: "conflicting".to_owned(), + }, + OperationRequest::CreateBranch { + branch: "blocked".to_owned(), + base: None, + }, + OperationRequest::Merge { + branch: "conflicting".to_owned(), + }, + OperationRequest::Rebase { + base: "conflicting".to_owned(), + }, + OperationRequest::OpenPullRequest { base: None }, + ] { + assert!( + planner + .plan_request(request) + .is_err_and(|error| error.contains("active merge")) + ); + } let Err(error) = planner.plan_prompt_sequence(vec![OperationRequest::StageAll, OperationRequest::Push]) @@ -5846,74 +5853,16 @@ mod tests { .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) .map_err(std::io::Error::other)?; let paths = isolated_store_paths("recovery-audit")?; - let recovery_supported = recovery_capabilities_available(&repo)?; - let execution = PlanExecutor::with_audit_paths(&repo, paths.clone()) .execute(&operation.plan, operation.context); - assert_eq!(execution.succeeded(), recovery_supported); - assert_eq!( - Git::new(&repo).status()?.operation, - (!recovery_supported).then_some(RepositoryOperation::Merge) - ); + assert!(execution.succeeded(), "{}", execution.message()); + assert_eq!(Git::new(&repo).status()?.operation, None); let entries = LocalStore::open(paths)?.list_audit_entries()?; assert_eq!(entries.len(), 2); assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); assert_eq!(entries[0].message, "pending"); - if recovery_supported { - assert_eq!(entries[1].result, "ok"); - } else { - assert_eq!(entries[1].result, "error"); - assert!( - execution - .message() - .contains("required platform capabilities are missing") - ); - } - Ok(()) - } - - #[test] - fn tui_reattaches_to_promoted_repository_after_recovery() -> Result<(), Box> { - if let Some(repo) = std::env::var_os("BITBYGIT_TEST_CWD_RECOVERY_REPO") { - let repo = PathBuf::from(repo); - let mut app = App::new(); - app.load_current_dir(); - app.submit_operation_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)); - app.handle_key(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); - - assert_eq!(app.repository_operation, None, "{}", app.details); - assert_eq!(current_dir().canonicalize()?, repo.canonicalize()?); - std::fs::write(repo.join("after-recovery.txt"), "promoted generation\n")?; - app.submit_operation_request(OperationRequest::StageAll); - app.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let status = Git::new(&repo).status()?; - assert!(status.staged_files().iter().any(|entry| { - entry.path == std::path::Path::new("after-recovery.txt") - && entry.index == ChangeKind::Added - })); - return Ok(()); - } - - let repo = merge_conflict_repo("cwd-recovery")?; - if !recovery_capabilities_available(&repo)? { - return Ok(()); - } - let output = std::process::Command::new(std::env::current_exe()?) - .args([ - "--exact", - "tests::tui_reattaches_to_promoted_repository_after_recovery", - "--nocapture", - ]) - .current_dir(&repo) - .env("BITBYGIT_TEST_CWD_RECOVERY_REPO", &repo) - .output()?; - assert!( - output.status.success(), - "cwd recovery child failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + assert_eq!(entries[1].result, "ok"); Ok(()) } @@ -5941,26 +5890,14 @@ mod tests { .plan_request(OperationRequest::Recover(request)) .map_err(std::io::Error::other)?; let paths = isolated_store_paths(&format!("recovery-{request:?}"))?; - let recovery_supported = recovery_capabilities_available(&repo)?; let execution = PlanExecutor::with_audit_paths(&repo, paths) .execute(&operation.plan, operation.context); - assert_eq!( + assert!( execution.succeeded(), - recovery_supported, "{request:?}: {}", execution.message() ); - assert_eq!( - Git::new(repo).status()?.operation, - (!recovery_supported).then_some(recovery_git_operation(request)) - ); - if !recovery_supported { - assert!( - execution - .message() - .contains("required platform capabilities are missing") - ); - } + assert_eq!(Git::new(repo).status()?.operation, None); } Ok(()) } diff --git a/docs/guardrails.md b/docs/guardrails.md index e9d2111..c76cfc3 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,12 +121,6 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -Atomic conflict recovery remains blocked on current platforms. User and mount -namespaces do not prevent another process with the same host identity from -writing a speculative repository, and Linux directory exchange cannot be -conditioned on inode identity. Recovery stays unavailable until both guarantees -can be enforced; capability probes are bounded and fail before a candidate is -created or repository state changes. ## Confirmation Copy From f315360c86311652736496bd5d960abec691f8d7 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:23:37 -0400 Subject: [PATCH 29/53] fix: bound exact recovery execution --- Cargo.lock | 75 +++ crates/bitbygit-git/Cargo.toml | 6 + crates/bitbygit-git/src/lib.rs | 922 +++++++++++++++++++++++++++++++-- crates/bitbygit-tui/src/lib.rs | 108 ++-- 4 files changed, 1029 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 155a9df..5e783a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,10 @@ dependencies = [ [[package]] name = "bitbygit-git" version = "0.1.0" +dependencies = [ + "rustix", + "sha2", +] [[package]] name = "bitbygit-store" @@ -63,6 +67,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -98,6 +111,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -123,6 +145,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -158,6 +190,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.16.0" @@ -192,6 +234,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -482,6 +534,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -605,6 +668,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -640,6 +709,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 9867516..4a347aa 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -8,3 +8,9 @@ rust-version.workspace = true [lints] workspace = true + +[dependencies] +sha2 = "0.10" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "0.38", features = ["process"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index c242b49..f08a6cb 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -4,13 +4,25 @@ use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus}; +use std::process::{Child, Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +#[cfg(unix)] +use rustix::process::{Pid, Signal, kill_process_group}; const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -21,12 +33,37 @@ const COMMIT_HOOKS: &[&str] = &[ "commit-msg", "post-commit", ]; +const RECOVERY_PLAN_TIMEOUT: Duration = Duration::from_secs(10); +const RECOVERY_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); +const RECOVERY_OUTPUT_LIMIT: usize = 256 * 1024; +const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; +const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; +const RECOVERY_CONTROL_PATHS: &[&str] = &[ + "HEAD", + "index", + "ORIG_HEAD", + "MERGE_HEAD", + "MERGE_MSG", + "MERGE_MODE", + "AUTO_MERGE", + "SQUASH_MSG", + "REBASE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "BISECT_HEAD", + "rebase-apply", + "rebase-merge", + "sequencer", +]; static NEXT_STAGED_FETCH_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone)] pub struct Git { cwd: PathBuf, ssh_executable: Option, + recovery_plan_timeout: Duration, + recovery_execution_timeout: Duration, + recovery_output_limit: usize, } impl Git { @@ -34,6 +71,9 @@ impl Git { Self { cwd: cwd.into(), ssh_executable: None, + recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, + recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, + recovery_output_limit: RECOVERY_OUTPUT_LIMIT, } } @@ -44,6 +84,25 @@ impl Git { Self { cwd: cwd.into(), ssh_executable: Some(ssh_executable.into()), + recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, + recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, + recovery_output_limit: RECOVERY_OUTPUT_LIMIT, + } + } + + #[cfg(test)] + fn with_recovery_limits( + cwd: impl Into, + plan_timeout: Duration, + execution_timeout: Duration, + output_limit: usize, + ) -> Self { + Self { + cwd: cwd.into(), + ssh_executable: None, + recovery_plan_timeout: plan_timeout, + recovery_execution_timeout: execution_timeout, + recovery_output_limit: output_limit, } } @@ -215,14 +274,92 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, ) -> Result { - if operation == RepositoryOperation::Merge && action == RecoveryAction::Skip { - return Err(GitError::Blocked { - message: "merge skip is blocked because Git does not support it".to_owned(), - }); + let deadline = Instant::now() + self.recovery_execution_timeout; + self.validate_recovery_until(operation, action, deadline)?; + self.run_recovery_args_until(operation, action, deadline) + } + + pub fn recovery_state(&self) -> Result { + self.recovery_state_until(Instant::now() + self.recovery_plan_timeout) + } + + pub fn prepare_recovery( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result { + let deadline = Instant::now() + self.recovery_plan_timeout; + self.validate_recovery_until(operation, action, deadline)?; + self.recovery_state_until(deadline) + } + + pub fn validate_recovery( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + self.validate_recovery_until( + operation, + action, + Instant::now() + self.recovery_plan_timeout, + ) + } + + pub fn recover_exact( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + expected_state: &RecoveryState, + ) -> Result { + self.recover_exact_with(operation, action, expected_state, || Ok(())) + } + + fn recover_exact_with( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + expected_state: &RecoveryState, + before_final_check: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + self.validate_recovery_action(operation, action)?; + let deadline = Instant::now() + self.recovery_execution_timeout; + self.ensure_recovery_state(expected_state, operation, action, deadline)?; + before_final_check()?; + self.ensure_recovery_state(expected_state, operation, action, deadline)?; + self.run_recovery_args_until(operation, action, deadline) + } + + fn ensure_recovery_state( + &self, + expected_state: &RecoveryState, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result<(), GitError> { + if self.recovery_state_until(deadline)? == *expected_state { + return Ok(()); } + Err(GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed after preview", + operation.label(), + action.label() + ), + }) + } - let status = self.status()?; - match status.operation { + fn validate_recovery_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result<(), GitError> { + self.validate_recovery_action(operation, action)?; + let active = self.repository_operation_until(deadline)?; + match active { Some(active) if active == operation => {} Some(active) => { return Err(GitError::Blocked { @@ -245,7 +382,7 @@ impl Git { }); } } - if action == RecoveryAction::Continue && !status.conflicted_files().is_empty() { + if action == RecoveryAction::Continue && self.has_unresolved_conflicts_until(deadline)? { return Err(GitError::Blocked { message: format!( "{} continue is blocked while unresolved conflicts are present", @@ -253,43 +390,20 @@ impl Git { ), }); } - - self.run_recovery_args(vec![ - operation.label().to_owned(), - format!("--{}", action.label()), - ]) - } - - pub fn recovery_state(&self) -> Result { - Ok(RecoveryState { - operation: self.repository_operation()?, - head: self.head_target()?, - status: self - .run_raw(["status", "--porcelain=v2", "--branch", "-z"])? - .stdout, - index: self.run_raw(["ls-files", "--stage", "-z"])?.stdout, - worktree: self - .run_raw(["diff", "--binary", "--no-ext-diff", "--full-index", "--"])? - .stdout, - }) + Ok(()) } - pub fn recover_exact( + fn validate_recovery_action( &self, operation: RepositoryOperation, action: RecoveryAction, - expected_state: &RecoveryState, - ) -> Result { - if self.recovery_state()? != *expected_state { + ) -> Result<(), GitError> { + if operation == RepositoryOperation::Merge && action == RecoveryAction::Skip { return Err(GitError::Blocked { - message: format!( - "{} {} is blocked because repository state changed after preview", - operation.label(), - action.label() - ), + message: "merge skip is blocked because Git does not support it".to_owned(), }); } - self.recover(operation, action) + Ok(()) } pub fn push_current_branch( @@ -1037,12 +1151,240 @@ impl Git { }) } + fn recovery_state_until(&self, deadline: Instant) -> Result { + let mut hasher = Sha256::new(); + for args in [ + vec![ + "status".to_owned(), + "--porcelain=v2".to_owned(), + "--branch".to_owned(), + "--untracked-files=all".to_owned(), + "-z".to_owned(), + ], + vec![ + "diff".to_owned(), + "--binary".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "--full-index".to_owned(), + "--".to_owned(), + ], + vec![ + "diff".to_owned(), + "--cached".to_owned(), + "--binary".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "--full-index".to_owned(), + "--".to_owned(), + ], + vec![ + "for-each-ref".to_owned(), + "--sort=refname".to_owned(), + "--format=%(refname)%00%(objectname)%00%(symref)%00".to_owned(), + ], + vec![ + "config".to_owned(), + "--null".to_owned(), + "--list".to_owned(), + "--show-origin".to_owned(), + "--show-scope".to_owned(), + ], + ] { + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + hash_field(&mut hasher, &output.stdout.digest); + } + + let mut entries = 0; + for relative in RECOVERY_CONTROL_PATHS { + let path = self.git_path_until(relative, deadline)?; + hash_recovery_path( + &mut hasher, + relative.as_bytes(), + &path, + deadline, + &mut entries, + )?; + } + let hooks = self.recovery_hooks_path_until(deadline)?; + hash_recovery_path(&mut hasher, b"hooks", &hooks, deadline, &mut entries)?; + + Ok(RecoveryState { + fingerprint: hasher.finalize().into(), + }) + } + + fn repository_operation_until( + &self, + deadline: Instant, + ) -> Result, GitError> { + let rebase_apply = self.git_path_until("rebase-apply", deadline)?; + if self.git_path_until("rebase-merge", deadline)?.exists() + || (rebase_apply.exists() && !rebase_apply.join("applying").exists()) + { + return Ok(Some(RepositoryOperation::Rebase)); + } + if self.git_path_until("MERGE_HEAD", deadline)?.exists() { + return Ok(Some(RepositoryOperation::Merge)); + } + Ok(None) + } + + fn has_unresolved_conflicts_until(&self, deadline: Instant) -> Result { + let args = vec![ + "diff".to_owned(), + "--quiet".to_owned(), + "--diff-filter=U".to_owned(), + "--".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + match output.status.code() { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => Err(output.git_error(args)), + } + } + + fn git_path_until(&self, path: &str, deadline: Instant) -> Result { + let args = vec![ + "rev-parse".to_owned(), + "--path-format=absolute".to_owned(), + "--git-path".to_owned(), + path.to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git path {path} is too long" + ), + }); + } + Ok(path_from_bytes(strip_byte_line_ending( + &output.stdout.bytes, + ))) + } + + fn recovery_hooks_path_until(&self, deadline: Instant) -> Result { + let args = vec![ + "config".to_owned(), + "--path".to_owned(), + "--get".to_owned(), + "core.hooksPath".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.success() { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because core.hooksPath is too long" + .to_owned(), + }); + } + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + return Ok(if path.is_absolute() { + path + } else { + let root = self.repo_root_until(deadline)?; + root.join(path) + }); + } + if output.status.code() == Some(1) { + return self.git_path_until("hooks", deadline); + } + Err(output.git_error(args)) + } + + fn repo_root_until(&self, deadline: Instant) -> Result { + let args = vec!["rev-parse".to_owned(), "--show-toplevel".to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because the repository path is too long" + .to_owned(), + }); + } + Ok(path_from_bytes(strip_byte_line_ending( + &output.stdout.bytes, + ))) + } + fn run_args(&self, args: Vec) -> Result { self.run_args_with_editor(args, false) } - fn run_recovery_args(&self, args: Vec) -> Result { - self.run_args_with_editor(args, true) + fn run_recovery_args_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result { + let args = vec![ + operation.label().to_owned(), + format!("--{}", action.label()), + ]; + let output = self.run_bounded_git(args.clone(), deadline, false)?; + let stdout = output.stdout.lossy_text(); + let stderr = output.stderr.lossy_text(); + if !output.status.success() { + return Err(GitError::GitFailed { + args, + status: output.status, + stdout, + stderr, + }); + } + Ok(GitOutput { + status: output.status, + stdout, + stderr, + }) + } + + fn run_bounded_git( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + ) -> Result { + let mut command = Command::new("git"); + command + .current_dir(&self.cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("SSH_ASKPASS_REQUIRE", "never") + .env("GIT_EDITOR", "true") + .env("GIT_SEQUENCE_EDITOR", "true") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(&args); + if optional_locks { + command.env("GIT_OPTIONAL_LOCKS", "0"); + } + if self.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { + let ssh_executable = self + .ssh_executable + .as_ref() + .map(|path| shell_quote(&path.to_string_lossy())) + .unwrap_or_else(|| "ssh".to_owned()); + command.env("GIT_SSH_COMMAND", format!("{ssh_executable} {SSH_OPTIONS}")); + } + configure_process_group(&mut command); + let output_limit = if optional_locks { + RECOVERY_DIAGNOSTIC_LIMIT + } else { + self.recovery_output_limit + }; + run_bounded_command(command, args, deadline, output_limit) } fn run_args_with_editor( @@ -1253,6 +1595,218 @@ impl Git { } } +fn run_bounded_command( + mut command: Command, + args: Vec, + deadline: Instant, + output_limit: usize, +) -> Result { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } + let mut child = command.spawn().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let stdout = child.stdout.take().ok_or_else(|| GitError::Io { + args: args.clone(), + source: io::Error::other("failed to capture git stdout"), + })?; + let stderr = child.stderr.take().ok_or_else(|| GitError::Io { + args: args.clone(), + source: io::Error::other("failed to capture git stderr"), + })?; + let stdout_reader = thread::spawn(move || read_captured_stream(stdout, output_limit)); + let stderr_reader = thread::spawn(move || read_captured_stream(stderr, output_limit)); + + let status = loop { + if let Some(status) = child.try_wait().map_err(|source| GitError::Io { + args: args.clone(), + source, + })? { + break status; + } + let now = Instant::now(); + if now >= deadline { + kill_process_tree(&mut child); + let _result = child.wait(); + let _stdout = join_captured_stream(stdout_reader, &args); + let _stderr = join_captured_stream(stderr_reader, &args); + return Err(GitError::TimedOut { args }); + } + thread::sleep(Duration::from_millis(5).min(deadline.saturating_duration_since(now))); + }; + + Ok(BoundedCommandOutput { + status, + stdout: join_captured_stream(stdout_reader, &args)?, + stderr: join_captured_stream(stderr_reader, &args)?, + }) +} + +fn read_captured_stream(mut stream: impl Read, output_limit: usize) -> io::Result { + let mut hasher = Sha256::new(); + let mut bytes = Vec::with_capacity(output_limit.min(8192)); + let mut truncated = false; + let mut buffer = [0; 8192]; + loop { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + let remaining = output_limit.saturating_sub(bytes.len()); + let retained = remaining.min(count); + bytes.extend_from_slice(&buffer[..retained]); + truncated |= retained < count; + } + Ok(CapturedStream { + bytes, + digest: hasher.finalize().into(), + truncated, + }) +} + +fn join_captured_stream( + reader: thread::JoinHandle>, + args: &[String], +) -> Result { + reader + .join() + .map_err(|_| GitError::Io { + args: args.to_vec(), + source: io::Error::other("git output reader stopped unexpectedly"), + })? + .map_err(|source| GitError::Io { + args: args.to_vec(), + source, + }) +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn kill_process_tree(child: &mut Child) { + if let Some(pid) = Pid::from_raw(child.id() as i32) { + let _result = kill_process_group(pid, Signal::Kill); + } + let _result = child.kill(); +} + +#[cfg(not(unix))] +fn kill_process_tree(child: &mut Child) { + let _result = child.kill(); +} + +fn hash_recovery_path( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, +) -> Result<(), GitError> { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + hash_field(hasher, label); + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + hash_field(hasher, b"missing"); + return Ok(()); + } + Err(source) => return Err(recovery_state_io(path, source)), + }; + *entries += 1; + hash_field(hasher, &metadata.len().to_le_bytes()); + #[cfg(unix)] + hash_field(hasher, &metadata.mode().to_le_bytes()); + #[cfg(not(unix))] + hash_field(hasher, &[u8::from(metadata.permissions().readonly())]); + + if metadata.file_type().is_symlink() { + hash_field(hasher, b"symlink"); + let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + hash_field(hasher, target.as_os_str().as_encoded_bytes()); + } else if metadata.is_file() { + hash_field(hasher, b"file"); + let mut file = fs::File::open(path).map_err(|source| recovery_state_io(path, source))?; + let mut buffer = [0; 64 * 1024]; + loop { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + let count = file + .read(&mut buffer) + .map_err(|source| recovery_state_io(path, source))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + } else if metadata.is_dir() { + hash_field(hasher, b"directory"); + let mut children = Vec::new(); + for child in fs::read_dir(path).map_err(|source| recovery_state_io(path, source))? { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: + "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + children.push(child.map_err(|source| recovery_state_io(path, source))?); + } + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + let mut child_label = label.to_vec(); + child_label.push(b'/'); + child_label.extend_from_slice(child.file_name().as_encoded_bytes()); + hash_recovery_path(hasher, &child_label, &child.path(), deadline, entries)?; + } + } else { + hash_field(hasher, b"special"); + } + Ok(()) +} + +fn ensure_recovery_fingerprint_capacity( + deadline: Instant, + entries: &usize, +) -> Result<(), GitError> { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + if *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + Ok(()) +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn recovery_state_io(path: &Path, source: io::Error) -> GitError { + GitError::Io { + args: vec![format!("recovery-state {}", path.display())], + source, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Repository { pub root: PathBuf, @@ -1311,6 +1865,44 @@ struct RawProcessOutput { stderr: Vec, } +#[derive(Debug)] +struct BoundedCommandOutput { + status: ExitStatus, + stdout: CapturedStream, + stderr: CapturedStream, +} + +impl BoundedCommandOutput { + fn git_error(self, args: Vec) -> GitError { + GitError::GitFailed { + args, + status: self.status, + stdout: self.stdout.lossy_text(), + stderr: self.stderr.lossy_text(), + } + } +} + +#[derive(Debug)] +struct CapturedStream { + bytes: Vec, + digest: [u8; 32], + truncated: bool, +} + +impl CapturedStream { + fn lossy_text(self) -> String { + let mut text = String::from_utf8_lossy(&self.bytes).into_owned(); + if self.truncated { + if !text.ends_with('\n') && !text.is_empty() { + text.push('\n'); + } + text.push_str("[output truncated by bitbygit]\n"); + } + text + } +} + #[derive(Debug)] pub enum GitError { Io { @@ -1328,6 +1920,9 @@ pub enum GitError { stdout: String, stderr: String, }, + TimedOut { + args: Vec, + }, Blocked { message: String, }, @@ -1371,6 +1966,9 @@ impl Display for GitError { args.join(" ") ) } + Self::TimedOut { args } => { + write!(formatter, "git {} timed out", args.join(" ")) + } Self::Blocked { message } => formatter.write_str(message), Self::Parse { message } => write!(formatter, "failed to parse git output: {message}"), } @@ -1382,7 +1980,10 @@ impl Error for GitError { match self { Self::Io { source, .. } => Some(source), Self::Utf8 { source, .. } => Some(source), - Self::GitFailed { .. } | Self::Blocked { .. } | Self::Parse { .. } => None, + Self::GitFailed { .. } + | Self::TimedOut { .. } + | Self::Blocked { .. } + | Self::Parse { .. } => None, } } } @@ -1454,11 +2055,7 @@ pub enum RecoveryAction { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecoveryState { - operation: Option, - head: HeadTarget, - status: Vec, - index: Vec, - worktree: Vec, + fingerprint: [u8; 32], } impl RecoveryAction { @@ -2608,6 +3205,241 @@ mod tests { Ok(()) } + #[test] + fn recovery_fingerprint_tracks_refs_index_config_hooks_and_progress() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; + + repo.run(["update-ref", "refs/heads/preview-race", "HEAD"])?; + assert_ne!(git.recovery_state()?, baseline); + repo.run(["update-ref", "-d", "refs/heads/preview-race"])?; + assert_eq!(git.recovery_state()?, baseline); + + repo.run(["config", "user.name", "changed after preview"])?; + assert_ne!(git.recovery_state()?, baseline); + repo.run(["config", "user.name", "bitbygit test"])?; + assert_eq!(git.recovery_state()?, baseline); + + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write(&hook, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + } + assert_ne!(git.recovery_state()?, baseline); + fs::remove_file(hook)?; + assert_eq!(git.recovery_state()?, baseline); + + repo.write("staged-after-preview.txt", "unexpected\n")?; + repo.run(["add", "staged-after-preview.txt"])?; + assert_ne!(git.recovery_state()?, baseline); + + let (rebase_repo, _original_head) = prepare_rebase_conflict()?; + let rebase_git = Git::new(rebase_repo.path()); + let rebase_baseline = rebase_git.recovery_state()?; + let orig_head = rebase_git.git_path("rebase-merge/orig-head")?; + fs::write(orig_head, format!("{ZERO_OID}\n"))?; + assert_ne!(rebase_git.recovery_state()?, rebase_baseline); + Ok(()) + } + + #[test] + fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Continue)?; + fs::write(git.git_path("MERGE_MSG")?, "substituted message\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + ) else { + return Err("expected changed merge metadata to block recovery".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } + + #[test] + fn exact_recovery_detects_synchronized_change_at_spawn_boundary() -> Result<(), Box> + { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Continue)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Continue, + &expected, + || { + fs::write(repo.path().join("late.txt"), "late staged content\n").map_err( + |source| GitError::Io { + args: vec!["write synchronized mutation".to_owned()], + source, + }, + )?; + let output = Command::new("git") + .current_dir(repo.path()) + .args(["add", "late.txt"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["add synchronized mutation".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected final spawn-boundary check to block recovery".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } + + #[test] + fn recovery_fingerprint_streams_large_binary_diffs_into_fixed_state() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + fs::write( + repo.path().join("conflict.txt"), + vec![0xa5; 8 * 1024 * 1024], + )?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_secs(5), + 4096, + ); + + let state = git.recovery_state()?; + + assert_eq!(std::mem::size_of_val(&state), 32); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_planning_times_out_hanging_status_hook() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let hook = repo.path().join("fsmonitor"); + fs::write(&hook, "#!/bin/sh\nsleep 30\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run_args(&["config", "core.fsmonitor", &hook.to_string_lossy()])?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let Err(error) = git.recovery_state() else { + return Err("expected hanging recovery planning hook to time out".into()); + }; + + assert!(matches!(error, GitError::TimedOut { .. })); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_caps_noisy_hook_output() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write( + &hook, + "#!/bin/sh\ndd if=/dev/zero bs=1024 count=1024 2>/dev/null\n", + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_secs(5), + 4096, + ); + + let output = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; + + assert!( + format!("{}{}", output.stdout, output.stderr).contains("output truncated by bitbygit") + ); + assert!(output.stdout.len() < 5000); + assert!(output.stderr.len() < 5000); + assert_eq!(git.status()?.operation, None); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_times_out_quiet_hanging_hook() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write(&hook, "#!/bin/sh\nsleep 30\n")?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_millis(200), + 4096, + ); + let started = Instant::now(); + + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected hanging recovery hook to time out".into()); + }; + + assert!(matches!(error, GitError::TimedOut { .. })); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn detects_both_rebase_backends_and_no_operation_in_clean_repo() -> Result<(), Box> { let repo = TempRepo::new()?; diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index c82ad30..4d9fad0 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -1912,7 +1912,9 @@ impl OperationPlanner { } fn plan_request(&self, request: OperationRequest) -> Result { - self.ensure_conflict_mode_allowed(std::slice::from_ref(&request))?; + if !matches!(request, OperationRequest::Recover(_)) { + self.ensure_conflict_mode_allowed(std::slice::from_ref(&request))?; + } if let Some(operation) = self.preflight_blocked_request(&request)? { return Ok(operation); } @@ -2363,26 +2365,23 @@ impl OperationPlanner { fn plan_recovery(&self, request: RecoveryRequest) -> Result { let git = self.git(); - let status = git - .status() - .map_err(|error| format!("Unable to prepare recovery plan: {error}"))?; let operation = recovery_git_operation(request); - if status.operation != Some(operation) { - return Err(recovery_state_error(request, status.operation)); - } - if matches!( - request, - RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue - ) && !status.conflicted_files().is_empty() - { - return Err(format!( - "{} continue blocked: resolve and stage all conflicts first.", - capitalize(request.operation_label()) - )); - } let state = git - .recovery_state() - .map_err(|error| format!("Unable to snapshot recovery state: {error}"))?; + .prepare_recovery(operation, recovery_git_action(request)) + .map_err(|error| { + if matches!( + request, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) && error.to_string().contains("unresolved conflicts") + { + format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(request.operation_label()) + ) + } else { + format!("Unable to snapshot recovery state: {error}") + } + })?; Ok(PreparedOperation::new( recovery_plan(request), ExecutionContext::from_payload(PendingPayload::Recovery { state }), @@ -2574,10 +2573,42 @@ fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<() }) { return Ok(()); } - let status = git - .status() - .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))?; - let operation = status.operation; + let recoveries = requests + .iter() + .filter_map(|request| match request { + OperationRequest::Recover(recovery) => Some(*recovery), + _ => None, + }) + .collect::>(); + if recoveries.len() > 1 { + return Err( + "Recovery sequence blocked: only one recovery action can be preflighted because it changes the active operation state." + .to_owned(), + ); + } + let operation = if let Some(recovery) = recoveries.first().copied() { + let operation = recovery_git_operation(recovery); + git.validate_recovery(operation, recovery_git_action(recovery)) + .map_err(|error| { + if matches!( + recovery, + RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue + ) && error.to_string().contains("unresolved conflicts") + { + format!( + "{} continue blocked: resolve and stage all conflicts first.", + capitalize(recovery.operation_label()) + ) + } else { + format!("Unable to validate conflict-mode policy: {error}") + } + })?; + Some(operation) + } else { + git.status() + .map_err(|error| format!("Unable to validate conflict-mode policy: {error}"))? + .operation + }; for request in requests { if let OperationRequest::Recover(recovery) = request @@ -2585,18 +2616,6 @@ fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<() { return Err(recovery_state_error(*recovery, operation)); } - if let OperationRequest::Recover(recovery) = request - && matches!( - recovery, - RecoveryRequest::MergeContinue | RecoveryRequest::RebaseContinue - ) - && !status.conflicted_files().is_empty() - { - return Err(format!( - "{} continue blocked: resolve and stage all conflicts first.", - capitalize(recovery.operation_label()) - )); - } if let Some(active) = operation && !conflict_mode_allows(request) { @@ -4863,6 +4882,7 @@ fn truncate_audit_message(message: &str) -> String { fn sanitized_git_error(error: &GitError) -> String { match error { GitError::GitFailed { status, .. } => format!("git failed with status {status}"), + GitError::TimedOut { .. } => "git timed out".to_owned(), GitError::Io { .. } => "git failed before execution".to_owned(), GitError::Utf8 { stream, .. } => format!("git returned non-UTF-8 {stream}"), GitError::Blocked { message } => format!("operation blocked: {message}"), @@ -5781,7 +5801,7 @@ mod tests { fn recovery_planner_matches_active_state_and_continue_prerequisites() -> Result<(), Box> { let repo = merge_conflict_repo("recovery-planner")?; - let planner = OperationPlanner::new(repo); + let planner = OperationPlanner::new(&repo); let Err(error) = planner.plan_prompt_sequence(vec![ OperationRequest::Fetch, @@ -5810,7 +5830,21 @@ mod tests { assert!( planner .plan_request(OperationRequest::Recover(RecoveryRequest::RebaseAbort)) - .is_err_and(|error| error.contains("does not match")) + .is_err_and(|error| error.contains("merge operation is active")) + ); + + let head_before = git_stdout(&repo, &["rev-parse", "HEAD"])?; + let Err(error) = planner.plan_prompt_sequence(vec![ + OperationRequest::Recover(RecoveryRequest::MergeAbort), + OperationRequest::Recover(RecoveryRequest::MergeAbort), + ]) else { + return Err("dependent recovery sequence should fail full preflight".into()); + }; + assert!(error.contains("only one recovery action can be preflighted")); + assert_eq!(git_stdout(&repo, &["rev-parse", "HEAD"])?, head_before); + assert_eq!( + Git::new(repo).status()?.operation, + Some(RepositoryOperation::Merge) ); Ok(()) } From 21e7413414e3ec00ebb78d4b5980eb271b9d0838 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:41:46 -0400 Subject: [PATCH 30/53] fix: harden bounded recovery invocation --- Cargo.lock | 68 +++- crates/bitbygit-git/Cargo.toml | 3 +- crates/bitbygit-git/src/lib.rs | 650 +++++++++++++++++++++++++++++---- docs/guardrails.md | 3 + 4 files changed, 652 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e783a1..f8c3c2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,8 +37,9 @@ dependencies = [ name = "bitbygit-git" version = "0.1.0" dependencies = [ - "rustix", + "rustix 0.38.44", "sha2", + "tempfile", ] [[package]] @@ -130,7 +131,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -222,6 +223,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fnv" version = "1.0.7" @@ -244,6 +251,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -332,6 +350,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -374,6 +398,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -421,6 +451,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ratatui" version = "0.29.0" @@ -460,10 +496,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -627,6 +676,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 4a347aa..8245654 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] sha2 = "0.10" +tempfile = "3" [target.'cfg(unix)'.dependencies] -rustix = { version = "0.38", features = ["process"] } +rustix = { version = "0.38", features = ["fs", "process"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index f08a6cb..23bf84a 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; -use std::io::{self, Read}; +use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; @@ -23,6 +23,11 @@ use std::os::unix::process::CommandExt; #[cfg(unix)] use rustix::process::{Pid, Signal, kill_process_group}; +#[cfg(unix)] +use rustix::{ + fs::{Mode, OFlags, open}, + io::Errno, +}; const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -54,6 +59,8 @@ const RECOVERY_CONTROL_PATHS: &[&str] = &[ "rebase-apply", "rebase-merge", "sequencer", + "info/attributes", + "info/sparse-checkout", ]; static NEXT_STAGED_FETCH_ID: AtomicU64 = AtomicU64::new(1); @@ -319,17 +326,17 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, expected_state: &RecoveryState, - before_final_check: F, + before_spawn_check: F, ) -> Result where F: FnOnce() -> Result<(), GitError>, { self.validate_recovery_action(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; - self.ensure_recovery_state(expected_state, operation, action, deadline)?; - before_final_check()?; - self.ensure_recovery_state(expected_state, operation, action, deadline)?; - self.run_recovery_args_until(operation, action, deadline) + self.run_recovery_args_until_with(operation, action, deadline, || { + before_spawn_check()?; + self.ensure_recovery_state(expected_state, operation, action, deadline) + }) } fn ensure_recovery_state( @@ -1211,6 +1218,29 @@ impl Git { } let hooks = self.recovery_hooks_path_until(deadline)?; hash_recovery_path(&mut hasher, b"hooks", &hooks, deadline, &mut entries)?; + for variable in ["GIT_ATTR_SYSTEM", "GIT_ATTR_GLOBAL"] { + if let Some(path) = self.git_var_path_until(variable, deadline)? { + hash_recovery_path( + &mut hasher, + variable.as_bytes(), + &path, + deadline, + &mut entries, + )?; + } + } + let root = self.repo_root_until(deadline)?; + for relative in self.worktree_attributes_until(deadline)? { + let mut label = b"worktree-attributes/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + hash_recovery_path( + &mut hasher, + &label, + &root.join(relative), + deadline, + &mut entries, + )?; + } Ok(RecoveryState { fingerprint: hasher.finalize().into(), @@ -1300,6 +1330,76 @@ impl Git { Err(output.git_error(args)) } + fn git_var_path_until( + &self, + variable: &str, + deadline: Instant, + ) -> Result, GitError> { + let args = vec!["var".to_owned(), variable.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.code() == Some(1) { + return Ok(None); + } + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery planning is blocked because {variable} is too long"), + }); + } + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + if !path.is_absolute() { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {variable} is not an absolute path" + ), + }); + } + Ok(Some(path)) + } + + fn worktree_attributes_until(&self, deadline: Instant) -> Result, GitError> { + let args = vec![ + "ls-files".to_owned(), + "--cached".to_owned(), + "--others".to_owned(), + "--ignored".to_owned(), + "--exclude-standard".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ":(glob)**/.gitattributes".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because attribute paths exceed the bounded output limit" + .to_owned(), + }); + } + let mut paths = BTreeSet::new(); + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid attribute path".to_owned(), + }); + } + paths.insert(path); + } + Ok(paths.into_iter().collect()) + } + fn repo_root_until(&self, deadline: Instant) -> Result { let args = vec!["rev-parse".to_owned(), "--show-toplevel".to_owned()]; let output = self.run_bounded_git(args.clone(), deadline, true)?; @@ -1327,11 +1427,24 @@ impl Git { action: RecoveryAction, deadline: Instant, ) -> Result { + self.run_recovery_args_until_with(operation, action, deadline, || Ok(())) + } + + fn run_recovery_args_until_with( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + before_spawn: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { let args = vec![ operation.label().to_owned(), format!("--{}", action.label()), ]; - let output = self.run_bounded_git(args.clone(), deadline, false)?; + let output = self.run_bounded_git_with(args.clone(), deadline, false, before_spawn)?; let stdout = output.stdout.lossy_text(); let stderr = output.stderr.lossy_text(); if !output.status.success() { @@ -1355,6 +1468,19 @@ impl Git { deadline: Instant, optional_locks: bool, ) -> Result { + self.run_bounded_git_with(args, deadline, optional_locks, || Ok(())) + } + + fn run_bounded_git_with( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + before_spawn: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { let mut command = Command::new("git"); command .current_dir(&self.cwd) @@ -1364,8 +1490,6 @@ impl Git { .env("SSH_ASKPASS_REQUIRE", "never") .env("GIT_EDITOR", "true") .env("GIT_SEQUENCE_EDITOR", "true") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) .args(&args); if optional_locks { command.env("GIT_OPTIONAL_LOCKS", "0"); @@ -1384,7 +1508,14 @@ impl Git { } else { self.recovery_output_limit }; - run_bounded_command(command, args, deadline, output_limit) + run_bounded_command( + command, + args, + deadline, + output_limit, + optional_locks, + before_spawn, + ) } fn run_args_with_editor( @@ -1595,29 +1726,50 @@ impl Git { } } -fn run_bounded_command( +fn run_bounded_command( mut command: Command, args: Vec, deadline: Instant, output_limit: usize, -) -> Result { + digest_all_output: bool, + before_spawn: F, +) -> Result +where + F: FnOnce() -> Result<(), GitError>, +{ if Instant::now() >= deadline { return Err(GitError::TimedOut { args }); } - let mut child = command.spawn().map_err(|source| GitError::Io { + let mut stdout = tempfile::tempfile().map_err(|source| GitError::Io { args: args.clone(), source, })?; - let stdout = child.stdout.take().ok_or_else(|| GitError::Io { + let mut stderr = tempfile::tempfile().map_err(|source| GitError::Io { args: args.clone(), - source: io::Error::other("failed to capture git stdout"), + source, })?; - let stderr = child.stderr.take().ok_or_else(|| GitError::Io { + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout.try_clone().map_err(|source| { + GitError::Io { + args: args.clone(), + source, + } + })?)) + .stderr(Stdio::from(stderr.try_clone().map_err(|source| { + GitError::Io { + args: args.clone(), + source, + } + })?)); + before_spawn()?; + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } + let mut child = command.spawn().map_err(|source| GitError::Io { args: args.clone(), - source: io::Error::other("failed to capture git stderr"), + source, })?; - let stdout_reader = thread::spawn(move || read_captured_stream(stdout, output_limit)); - let stderr_reader = thread::spawn(move || read_captured_stream(stderr, output_limit)); let status = loop { if let Some(status) = child.try_wait().map_err(|source| GitError::Io { @@ -1630,57 +1782,87 @@ fn run_bounded_command( if now >= deadline { kill_process_tree(&mut child); let _result = child.wait(); - let _stdout = join_captured_stream(stdout_reader, &args); - let _stderr = join_captured_stream(stderr_reader, &args); return Err(GitError::TimedOut { args }); } thread::sleep(Duration::from_millis(5).min(deadline.saturating_duration_since(now))); }; + // Hooks may leave background children behind. End the command's process group + // before reading finite file snapshots; detached children cannot hold these + // captures open as they could inherited pipes. + kill_process_tree(&mut child); Ok(BoundedCommandOutput { status, - stdout: join_captured_stream(stdout_reader, &args)?, - stderr: join_captured_stream(stderr_reader, &args)?, + stdout: read_captured_file( + &mut stdout, + output_limit, + digest_all_output, + deadline, + &args, + )?, + stderr: read_captured_file( + &mut stderr, + output_limit, + digest_all_output, + deadline, + &args, + )?, }) } -fn read_captured_stream(mut stream: impl Read, output_limit: usize) -> io::Result { +fn read_captured_file( + file: &mut fs::File, + output_limit: usize, + digest_all_output: bool, + deadline: Instant, + args: &[String], +) -> Result { + file.seek(SeekFrom::Start(0)) + .map_err(|source| recovery_output_io(args, source))?; + let snapshot_len = file + .metadata() + .map_err(|source| recovery_output_io(args, source))? + .len(); + let read_limit = if digest_all_output { + snapshot_len + } else { + snapshot_len.min(output_limit.saturating_add(1) as u64) + }; let mut hasher = Sha256::new(); let mut bytes = Vec::with_capacity(output_limit.min(8192)); - let mut truncated = false; + let mut remaining = read_limit; let mut buffer = [0; 8192]; - loop { - let count = stream.read(&mut buffer)?; + while remaining > 0 { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: args.to_vec(), + }); + } + let requested = buffer.len().min(remaining as usize); + let count = file + .read(&mut buffer[..requested]) + .map_err(|source| recovery_output_io(args, source))?; if count == 0 { break; } + remaining -= count as u64; hasher.update(&buffer[..count]); let remaining = output_limit.saturating_sub(bytes.len()); let retained = remaining.min(count); bytes.extend_from_slice(&buffer[..retained]); - truncated |= retained < count; } Ok(CapturedStream { bytes, digest: hasher.finalize().into(), - truncated, + truncated: snapshot_len > output_limit as u64, }) } -fn join_captured_stream( - reader: thread::JoinHandle>, - args: &[String], -) -> Result { - reader - .join() - .map_err(|_| GitError::Io { - args: args.to_vec(), - source: io::Error::other("git output reader stopped unexpectedly"), - })? - .map_err(|source| GitError::Io { - args: args.to_vec(), - source, - }) +fn recovery_output_io(args: &[String], source: io::Error) -> GitError { + GitError::Io { + args: args.to_vec(), + source, + } } #[cfg(unix)] @@ -1710,17 +1892,58 @@ fn hash_recovery_path( path: &Path, deadline: Instant, entries: &mut usize, +) -> Result<(), GitError> { + hash_recovery_path_inner(hasher, label, path, deadline, entries, 0) +} + +fn hash_recovery_path_inner( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + symlink_depth: usize, ) -> Result<(), GitError> { ensure_recovery_fingerprint_capacity(deadline, entries)?; hash_field(hasher, label); - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => { + let opened = match open_recovery_file(path) { + Ok(opened) => opened, + Err(RecoveryOpenError::Missing) => { hash_field(hasher, b"missing"); return Ok(()); } - Err(source) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Symlink) => { + if symlink_depth >= 16 { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} has too many symlink levels", + path.display() + ), + }); + } + *entries += 1; + hash_field(hasher, b"symlink"); + let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + hash_field(hasher, target.as_os_str().as_encoded_bytes()); + ensure_recovery_fingerprint_capacity(deadline, entries)?; + let target = if target.is_absolute() { + target + } else { + path.parent().unwrap_or_else(|| Path::new(".")).join(target) + }; + return hash_recovery_path_inner( + hasher, + label, + &target, + deadline, + entries, + symlink_depth + 1, + ); + } + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), }; + let mut file = opened.file; + let metadata = opened.metadata; *entries += 1; hash_field(hasher, &metadata.len().to_le_bytes()); #[cfg(unix)] @@ -1728,13 +1951,8 @@ fn hash_recovery_path( #[cfg(not(unix))] hash_field(hasher, &[u8::from(metadata.permissions().readonly())]); - if metadata.file_type().is_symlink() { - hash_field(hasher, b"symlink"); - let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; - hash_field(hasher, target.as_os_str().as_encoded_bytes()); - } else if metadata.is_file() { + if metadata.is_file() { hash_field(hasher, b"file"); - let mut file = fs::File::open(path).map_err(|source| recovery_state_io(path, source))?; let mut buffer = [0; 64 * 1024]; loop { if Instant::now() >= deadline { @@ -1750,33 +1968,189 @@ fn hash_recovery_path( } hasher.update(&buffer[..count]); } - } else if metadata.is_dir() { - hash_field(hasher, b"directory"); - let mut children = Vec::new(); - for child in fs::read_dir(path).map_err(|source| recovery_state_io(path, source))? { - ensure_recovery_fingerprint_capacity(deadline, entries)?; - if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + let final_metadata = file + .metadata() + .map_err(|source| recovery_state_io(path, source))?; + let path_metadata = match open_recovery_file(path) { + Ok(reopened) => reopened.metadata, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { return Err(GitError::Blocked { - message: - "recovery planning is blocked because recovery state has too many entries" - .to_owned(), + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), }); } - children.push(child.map_err(|source| recovery_state_io(path, source))?); + }; + if !same_recovery_file(&metadata, &final_metadata) + || !same_recovery_file(&metadata, &path_metadata) + { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + }); } - children.sort_by_key(fs::DirEntry::file_name); + } else if metadata.is_dir() { + hash_field(hasher, b"directory"); + let mut children = recovery_directory_children(&file, path, deadline, entries)?; + children.sort(); for child in children { + ensure_recovery_fingerprint_capacity(deadline, entries)?; let mut child_label = label.to_vec(); child_label.push(b'/'); - child_label.extend_from_slice(child.file_name().as_encoded_bytes()); - hash_recovery_path(hasher, &child_label, &child.path(), deadline, entries)?; + child_label.extend_from_slice(child.as_encoded_bytes()); + hash_recovery_path_inner( + hasher, + &child_label, + &path.join(child), + deadline, + entries, + symlink_depth, + )?; + } + let final_metadata = file + .metadata() + .map_err(|source| recovery_state_io(path, source))?; + if !same_recovery_file(&metadata, &final_metadata) { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + }); } } else { - hash_field(hasher, b"special"); + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {} is not a regular file or directory", + path.display() + ), + }); } Ok(()) } +struct RecoveryFile { + file: fs::File, + metadata: fs::Metadata, +} + +enum RecoveryOpenError { + Missing, + Symlink, + Io(io::Error), +} + +#[cfg(unix)] +fn recovery_directory_children( + file: &fs::File, + path: &Path, + deadline: Instant, + entries: &usize, +) -> Result, GitError> { + use rustix::fs::Dir; + + let mut children = Vec::new(); + let directory = + Dir::read_from(file).map_err(|source| recovery_state_io(path, io::Error::from(source)))?; + for child in directory { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + let child = child.map_err(|source| recovery_state_io(path, io::Error::from(source)))?; + let name = child.file_name().to_bytes(); + if name != b"." && name != b".." { + children.push(OsString::from_vec(name.to_vec())); + } + } + Ok(children) +} + +#[cfg(not(unix))] +fn recovery_directory_children( + _file: &fs::File, + path: &Path, + deadline: Instant, + entries: &usize, +) -> Result, GitError> { + let mut children = Vec::new(); + for child in fs::read_dir(path).map_err(|source| recovery_state_io(path, source))? { + ensure_recovery_fingerprint_capacity(deadline, entries)?; + if children.len() + *entries >= RECOVERY_STATE_ENTRY_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery state has too many entries" + .to_owned(), + }); + } + children.push( + child + .map_err(|source| recovery_state_io(path, source))? + .file_name(), + ); + } + Ok(children) +} + +#[cfg(unix)] +fn open_recovery_file(path: &Path) -> Result { + let descriptor = match open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(Errno::NOENT) => return Err(RecoveryOpenError::Missing), + Err(Errno::LOOP) => return Err(RecoveryOpenError::Symlink), + Err(source) => return Err(RecoveryOpenError::Io(source.into())), + }; + let file = fs::File::from(descriptor); + let metadata = file.metadata().map_err(RecoveryOpenError::Io)?; + Ok(RecoveryFile { file, metadata }) +} + +#[cfg(not(unix))] +fn open_recovery_file(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Err(RecoveryOpenError::Missing); + } + Err(source) => return Err(RecoveryOpenError::Io(source)), + }; + if metadata.file_type().is_symlink() { + return Err(RecoveryOpenError::Symlink); + } + let file = fs::File::open(path).map_err(RecoveryOpenError::Io)?; + Ok(RecoveryFile { file, metadata }) +} + +#[cfg(unix)] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.dev() == right.dev() + && left.ino() == right.ino() + && left.mode() == right.mode() + && left.len() == right.len() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(not(unix))] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.is_file() == right.is_file() + && left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + fn ensure_recovery_fingerprint_capacity( deadline: Instant, entries: &usize, @@ -3248,6 +3622,100 @@ mod tests { Ok(()) } + #[test] + fn recovery_fingerprint_tracks_attributes_and_sparse_checkout_inputs() + -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let external = TempRepo::new()?; + let external_attributes = external.path().join("attributes"); + fs::write(&external_attributes, "*.txt text\n")?; + repo.run_args(&[ + "config", + "core.attributesFile", + &external_attributes.to_string_lossy(), + ])?; + repo.write(".gitattributes", "*.txt text\n")?; + let git = Git::new(repo.path()); + let info_attributes = git.git_path("info/attributes")?; + let sparse_checkout = git.git_path("info/sparse-checkout")?; + fs::write(&info_attributes, "*.md text\n")?; + fs::write(&sparse_checkout, "/*\n")?; + let baseline = git.recovery_state()?; + + for (path, changed, original) in [ + ( + repo.path().join(".gitattributes"), + "*.txt binary\n", + "*.txt text\n", + ), + (info_attributes.clone(), "*.md binary\n", "*.md text\n"), + (sparse_checkout.clone(), "/src/\n", "/*\n"), + ( + external_attributes.clone(), + "*.txt binary\n", + "*.txt text\n", + ), + ] { + fs::write(&path, changed)?; + assert_ne!(git.recovery_state()?, baseline, "{}", path.display()); + fs::write(path, original)?; + assert_eq!(git.recovery_state()?, baseline); + } + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_fingerprint_tracks_symlinked_hook_target_contents() -> Result<(), Box> { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let (repo, _original_head) = prepare_merge_conflict()?; + let external = TempRepo::new()?; + let target = external.path().join("commit-msg-target"); + fs::write(&target, "#!/bin/sh\nexit 0\n")?; + let mut permissions = fs::metadata(&target)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&target, permissions)?; + symlink(&target, repo.path().join(".git/hooks/commit-msg"))?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; + + fs::write(&target, "#!/bin/sh\nexit 1\n")?; + + assert_ne!(git.recovery_state()?, baseline); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_fifo_without_blocking() -> Result<(), Box> { + let repo = TempRepo::new()?; + let fifo = repo.path().join("attributes.fifo"); + let status = Command::new("mkfifo").arg(&fifo).status()?; + assert!(status.success()); + let mut hasher = Sha256::new(); + let mut entries = 0; + let started = Instant::now(); + + let Err(error) = hash_recovery_path( + &mut hasher, + b"fifo", + &fifo, + Instant::now() + Duration::from_millis(200), + &mut entries, + ) else { + return Err("expected FIFO recovery input to be rejected".into()); + }; + + assert!( + error + .to_string() + .contains("not a regular file or directory") + ); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; @@ -3440,6 +3908,52 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_does_not_wait_for_background_hook_pipe_holders() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let background_pid = repo.path().join("background.pid"); + let detached_pid = repo.path().join("detached.pid"); + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write( + &hook, + format!( + "#!/bin/sh\nsleep 30 &\nprintf '%s' $! > '{}'\nsetsid sleep 30 &\nprintf '%s' $! > '{}'\n", + background_pid.display(), + detached_pid.display() + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let result = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue); + + for pid_file in [&background_pid, &detached_pid] { + if let Ok(pid) = fs::read_to_string(pid_file) { + let _status = Command::new("kill") + .args(["-KILL", pid.trim()]) + .stderr(Stdio::null()) + .status(); + } + } + result?; + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(git.status()?.operation, None); + Ok(()) + } + #[test] fn detects_both_rebase_backends_and_no_operation_in_clean_repo() -> Result<(), Box> { let repo = TempRepo::new()?; diff --git a/docs/guardrails.md b/docs/guardrails.md index c76cfc3..b8c2c1c 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,6 +121,9 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. +BitByGit revalidates bounded recovery inputs immediately before invoking Git and +surfaces Git's own operation-state failures. This narrows but does not claim to +eliminate the cross-process scheduling window for external worktree writers. ## Confirmation Copy From 0c22e68e3d1ae322f22535fed453949d54805961 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:44:04 -0400 Subject: [PATCH 31/53] fix: hard-cap recovery output files --- crates/bitbygit-git/src/lib.rs | 82 +++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 23bf84a..baed55e 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -21,6 +21,8 @@ use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] +use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, ftruncate, memfd_create}; #[cfg(unix)] use rustix::process::{Pid, Signal, kill_process_group}; #[cfg(unix)] @@ -42,6 +44,8 @@ const RECOVERY_PLAN_TIMEOUT: Duration = Duration::from_secs(10); const RECOVERY_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); const RECOVERY_OUTPUT_LIMIT: usize = 256 * 1024; const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; +const RECOVERY_EXECUTION_CAPTURE_LIMIT: usize = 4 * 1024 * 1024; +const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; const RECOVERY_CONTROL_PATHS: &[&str] = &[ "HEAD", @@ -1740,14 +1744,13 @@ where if Instant::now() >= deadline { return Err(GitError::TimedOut { args }); } - let mut stdout = tempfile::tempfile().map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; - let mut stderr = tempfile::tempfile().map_err(|source| GitError::Io { - args: args.clone(), - source, - })?; + let capture_limit = if digest_all_output { + RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT + } else { + RECOVERY_EXECUTION_CAPTURE_LIMIT.max(output_limit.saturating_add(1)) + }; + let mut stdout = bounded_capture_file(capture_limit, &args)?; + let mut stderr = bounded_capture_file(capture_limit, &args)?; command .stdin(Stdio::null()) .stdout(Stdio::from(stdout.try_clone().map_err(|source| { @@ -1790,11 +1793,14 @@ where // before reading finite file snapshots; detached children cannot hold these // captures open as they could inherited pipes. kill_process_tree(&mut child); + let stdout_len = captured_file_position(&mut stdout, capture_limit, &args)?; + let stderr_len = captured_file_position(&mut stderr, capture_limit, &args)?; Ok(BoundedCommandOutput { status, stdout: read_captured_file( &mut stdout, + stdout_len, output_limit, digest_all_output, deadline, @@ -1802,6 +1808,7 @@ where )?, stderr: read_captured_file( &mut stderr, + stderr_len, output_limit, digest_all_output, deadline, @@ -1812,6 +1819,7 @@ where fn read_captured_file( file: &mut fs::File, + snapshot_len: u64, output_limit: usize, digest_all_output: bool, deadline: Instant, @@ -1819,10 +1827,6 @@ fn read_captured_file( ) -> Result { file.seek(SeekFrom::Start(0)) .map_err(|source| recovery_output_io(args, source))?; - let snapshot_len = file - .metadata() - .map_err(|source| recovery_output_io(args, source))? - .len(); let read_limit = if digest_all_output { snapshot_len } else { @@ -1858,6 +1862,47 @@ fn read_captured_file( }) } +#[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] +fn bounded_capture_file(capacity: usize, args: &[String]) -> Result { + let descriptor = memfd_create( + "bitbygit-recovery-output", + MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING, + ) + .map_err(|source| recovery_output_io(args, source.into()))?; + ftruncate(&descriptor, capacity as u64) + .map_err(|source| recovery_output_io(args, source.into()))?; + fcntl_add_seals( + &descriptor, + SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL, + ) + .map_err(|source| recovery_output_io(args, source.into()))?; + Ok(fs::File::from(descriptor)) +} + +#[cfg(not(any(target_os = "android", target_os = "freebsd", target_os = "linux")))] +fn bounded_capture_file(_capacity: usize, args: &[String]) -> Result { + tempfile::tempfile().map_err(|source| recovery_output_io(args, source)) +} + +fn captured_file_position( + file: &mut fs::File, + capacity: usize, + args: &[String], +) -> Result { + let position = file + .stream_position() + .map_err(|source| recovery_output_io(args, source))?; + if position > capacity as u64 { + return Err(GitError::Blocked { + message: format!( + "git {} output exceeded the bounded capture limit", + args.join(" ") + ), + }); + } + Ok(position) +} + fn recovery_output_io(args: &[String], source: io::Error) -> GitError { GitError::Io { args: args.to_vec(), @@ -3716,6 +3761,19 @@ mod tests { Ok(()) } + #[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] + #[test] + fn recovery_output_file_has_a_hard_growth_limit() -> Result<(), Box> { + use std::io::Write; + + let args = ["merge".to_owned(), "--continue".to_owned()]; + let mut capture = bounded_capture_file(4096, &args)?; + capture.write_all(&vec![b'x'; 4096])?; + + assert!(capture.write_all(b"x").is_err()); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; From 88566001d4cdf87794d859739d8d74cee75aae10 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 16:01:58 -0400 Subject: [PATCH 32/53] fix: close recovery execution gaps --- .github/workflows/ci.yml | 20 ++ crates/bitbygit-git/src/lib.rs | 349 +++++++++++++++++++++++++++++++-- docs/guardrails.md | 4 + 3 files changed, 357 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac0463a..3401330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,23 @@ jobs: - name: Build run: cargo build --locked --workspace + + recovery-platforms: + name: Recovery safety (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Verify fail-closed recovery execution + run: cargo test --locked -p bitbygit-git recovery_execution_capability diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index baed55e..3c5ea75 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -54,6 +54,8 @@ const RECOVERY_CONTROL_PATHS: &[&str] = &[ "MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", + "MERGE_AUTOSTASH", + "MERGE_RR", "AUTO_MERGE", "SQUASH_MSG", "REBASE_HEAD", @@ -63,6 +65,7 @@ const RECOVERY_CONTROL_PATHS: &[&str] = &[ "rebase-apply", "rebase-merge", "sequencer", + "rr-cache", "info/attributes", "info/sparse-checkout", ]; @@ -285,6 +288,7 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, ) -> Result { + ensure_recovery_execution_supported(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; self.validate_recovery_until(operation, action, deadline)?; self.run_recovery_args_until(operation, action, deadline) @@ -335,6 +339,7 @@ impl Git { where F: FnOnce() -> Result<(), GitError>, { + ensure_recovery_execution_supported(operation, action)?; self.validate_recovery_action(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; self.run_recovery_args_until_with(operation, action, deadline, || { @@ -1931,6 +1936,31 @@ fn kill_process_tree(child: &mut Child) { let _result = child.kill(); } +#[cfg(target_os = "linux")] +fn ensure_recovery_execution_supported( + _operation: RepositoryOperation, + _action: RecoveryAction, +) -> Result<(), GitError> { + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_recovery_execution_supported( + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result<(), GitError> { + Err(GitError::Blocked { + message: format!( + "{} {} is blocked on {} because bitbygit cannot guarantee bounded Git output and descendant process cleanup on this platform; review the repository state and run `git {} --{}` manually, or use bitbygit on Linux", + operation.label(), + action.label(), + env::consts::OS, + operation.label(), + action.label() + ), + }) +} + fn hash_recovery_path( hasher: &mut Sha256, label: &[u8], @@ -1938,17 +1968,35 @@ fn hash_recovery_path( deadline: Instant, entries: &mut usize, ) -> Result<(), GitError> { - hash_recovery_path_inner(hasher, label, path, deadline, entries, 0) + hash_recovery_path_with(hasher, label, path, deadline, entries, &mut |_, _| Ok(())) +} + +fn hash_recovery_path_with( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + after_contents: &mut F, +) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ + hash_recovery_path_inner(hasher, label, path, deadline, entries, 0, after_contents) } -fn hash_recovery_path_inner( +fn hash_recovery_path_inner( hasher: &mut Sha256, label: &[u8], path: &Path, deadline: Instant, entries: &mut usize, symlink_depth: usize, -) -> Result<(), GitError> { + after_contents: &mut F, +) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ ensure_recovery_fingerprint_capacity(deadline, entries)?; hash_field(hasher, label); let opened = match open_recovery_file(path) { @@ -1968,22 +2016,38 @@ fn hash_recovery_path_inner( } *entries += 1; hash_field(hasher, b"symlink"); + let metadata = recovery_symlink_metadata(path)?; let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let read_metadata = recovery_symlink_metadata(path)?; + if !same_recovery_file(&metadata, &read_metadata) { + return Err(recovery_path_changed(path)); + } hash_field(hasher, target.as_os_str().as_encoded_bytes()); ensure_recovery_fingerprint_capacity(deadline, entries)?; - let target = if target.is_absolute() { - target + let resolved_target = if target.is_absolute() { + target.clone() } else { - path.parent().unwrap_or_else(|| Path::new(".")).join(target) + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(&target) }; - return hash_recovery_path_inner( + hash_recovery_path_inner( hasher, label, - &target, + &resolved_target, deadline, entries, symlink_depth + 1, - ); + after_contents, + )?; + after_contents(path, RecoveryPathKind::Symlink)?; + let final_target = + fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let final_metadata = recovery_symlink_metadata(path)?; + if final_target != target || !same_recovery_file(&metadata, &final_metadata) { + return Err(recovery_path_changed(path)); + } + return Ok(()); } Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), }; @@ -2054,18 +2118,24 @@ fn hash_recovery_path_inner( deadline, entries, symlink_depth, + after_contents, )?; } + after_contents(path, RecoveryPathKind::Directory)?; let final_metadata = file .metadata() .map_err(|source| recovery_state_io(path, source))?; - if !same_recovery_file(&metadata, &final_metadata) { - return Err(GitError::Blocked { - message: format!( - "recovery planning is blocked because {} changed while it was fingerprinted", - path.display() - ), - }); + let path_metadata = match open_recovery_file(path) { + Ok(reopened) => reopened.metadata, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !same_recovery_file(&metadata, &final_metadata) + || !same_recovery_file(&metadata, &path_metadata) + { + return Err(recovery_path_changed(path)); } } else { return Err(GitError::Blocked { @@ -2078,6 +2148,29 @@ fn hash_recovery_path_inner( Ok(()) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecoveryPathKind { + Symlink, + Directory, +} + +fn recovery_symlink_metadata(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|source| recovery_state_io(path, source))?; + if !metadata.file_type().is_symlink() { + return Err(recovery_path_changed(path)); + } + Ok(metadata) +} + +fn recovery_path_changed(path: &Path) -> GitError { + GitError::Blocked { + message: format!( + "recovery planning is blocked because {} changed while it was fingerprinted", + path.display() + ), + } +} + struct RecoveryFile { file: fs::File, metadata: fs::Metadata, @@ -3667,6 +3760,33 @@ mod tests { Ok(()) } + #[test] + fn recovery_fingerprint_tracks_autostash_and_rerere_state() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; + + for relative in ["MERGE_AUTOSTASH", "MERGE_RR"] { + let path = git.git_path(relative)?; + fs::write(&path, format!("{original_head}\n"))?; + assert_ne!(git.recovery_state()?, baseline, "{relative}"); + fs::remove_file(path)?; + assert_eq!(git.recovery_state()?, baseline, "{relative}"); + } + + let rr_cache = git.git_path("rr-cache")?; + let rr_cache_existed = rr_cache.exists(); + fs::create_dir_all(&rr_cache)?; + fs::write(rr_cache.join("review-regression"), "changed rerere state\n")?; + assert_ne!(git.recovery_state()?, baseline, "rr-cache"); + fs::remove_file(rr_cache.join("review-regression"))?; + if !rr_cache_existed { + fs::remove_dir(rr_cache)?; + } + assert_eq!(git.recovery_state()?, baseline, "rr-cache"); + Ok(()) + } + #[test] fn recovery_fingerprint_tracks_attributes_and_sparse_checkout_inputs() -> Result<(), Box> { @@ -3731,6 +3851,96 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_synchronized_symlink_retarget() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let repo = TempRepo::new()?; + let first = repo.path().join("first-hook"); + let second = repo.path().join("second-hook"); + let hook = repo.path().join("commit-msg"); + let replacement = repo.path().join("replacement-link"); + fs::write(&first, "same hook contents\n")?; + fs::write(&second, "same hook contents\n")?; + symlink(&first, &hook)?; + symlink(&second, &replacement)?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"hook", + &hook, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == hook && kind == RecoveryPathKind::Symlink { + fs::rename(&replacement, &hook) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected a retargeted symlink to be rejected".into()); + }; + + assert!(replaced); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_fingerprint_rejects_synchronized_directory_replacement() + -> Result<(), Box> { + let repo = TempRepo::new()?; + let hooks = repo.path().join("hooks"); + let original = repo.path().join("original-hooks"); + let replacement = repo.path().join("replacement-hooks"); + fs::create_dir(&hooks)?; + fs::write(hooks.join("commit-msg"), "same hook contents\n")?; + fs::create_dir(&replacement)?; + fs::write(replacement.join("commit-msg"), "same hook contents\n")?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"hooks", + &hooks, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == hooks && kind == RecoveryPathKind::Directory { + fs::rename(&hooks, &original) + .map_err(|source| recovery_state_io(path, source))?; + fs::rename(&replacement, &hooks) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected a replaced directory to be rejected".into()); + }; + + assert!(replaced); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_fingerprint_rejects_fifo_without_blocking() -> Result<(), Box> { @@ -3801,6 +4011,31 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_changed_merge_autostash() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let autostash = git.git_path("MERGE_AUTOSTASH")?; + fs::write(&autostash, format!("{original_head}\n"))?; + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let merge_head = fs::read_to_string(git.git_path("MERGE_HEAD")?)?; + fs::write(&autostash, merge_head)?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed merge autostash to block recovery".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } + #[test] fn exact_recovery_detects_synchronized_change_at_spawn_boundary() -> Result<(), Box> { @@ -3966,6 +4201,88 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_timeout_terminates_hook_descendants() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let descendant_pid = repo.path().join("descendant.pid"); + let hook = repo.path().join(".git/hooks/commit-msg"); + fs::write( + &hook, + format!( + "#!/bin/sh\nsleep 30 &\nprintf '%s' $! > '{}'\nwait\n", + descendant_pid.display() + ), + )?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_secs(10), + Duration::from_millis(200), + 4096, + ); + + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected hanging recovery hook to time out".into()); + }; + + assert!(matches!(error, GitError::TimedOut { .. })); + let pid = fs::read_to_string(descendant_pid)?; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline + && Command::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status()? + .success() + { + thread::sleep(Duration::from_millis(10)); + } + assert!( + !Command::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status()? + .success(), + "hook descendant {pid} survived recovery timeout" + ); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_execution_capability_is_available() -> Result<(), Box> { + ensure_recovery_execution_supported(RepositoryOperation::Merge, RecoveryAction::Abort)?; + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn recovery_execution_capability_fails_closed_after_planning() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected recovery execution to fail closed".into()); + }; + + let message = error.to_string(); + assert!(message.contains("cannot guarantee bounded Git output")); + assert!(message.contains("descendant process cleanup")); + assert!(message.contains("git merge --abort")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_does_not_wait_for_background_hook_pipe_holders() -> Result<(), Box> { diff --git a/docs/guardrails.md b/docs/guardrails.md index b8c2c1c..6829f2a 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -124,6 +124,10 @@ Conflict recovery actions are high risk because they move repository state. BitByGit revalidates bounded recovery inputs immediately before invoking Git and surfaces Git's own operation-state failures. This narrows but does not claim to eliminate the cross-process scheduling window for external worktree writers. +Recovery planning remains available on all release platforms. Recovery execution +fails closed with a manual-command diagnostic on platforms where BitByGit cannot +guarantee both hard output bounds and descendant process cleanup; currently, +execution is supported on Linux. ## Confirmation Copy From 09a6afce07c8eef40c14d8d71cc90513cfbdc1ac Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 16:04:45 -0400 Subject: [PATCH 33/53] fix: preserve recovery planning on Windows --- crates/bitbygit-git/src/lib.rs | 43 ++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 3c5ea75..5edb5ef 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -2062,6 +2062,12 @@ where if metadata.is_file() { hash_field(hasher, b"file"); + let Some(file) = file.as_mut() else { + return Err(recovery_state_io( + path, + io::Error::other("regular recovery input has no open descriptor"), + )); + }; let mut buffer = [0; 64 * 1024]; loop { if Instant::now() >= deadline { @@ -2104,7 +2110,7 @@ where } } else if metadata.is_dir() { hash_field(hasher, b"directory"); - let mut children = recovery_directory_children(&file, path, deadline, entries)?; + let mut children = recovery_directory_children(file.as_ref(), path, deadline, entries)?; children.sort(); for child in children { ensure_recovery_fingerprint_capacity(deadline, entries)?; @@ -2122,9 +2128,12 @@ where )?; } after_contents(path, RecoveryPathKind::Directory)?; - let final_metadata = file - .metadata() - .map_err(|source| recovery_state_io(path, source))?; + let final_metadata = match file.as_ref() { + Some(file) => file + .metadata() + .map_err(|source| recovery_state_io(path, source))?, + None => fs::symlink_metadata(path).map_err(|source| recovery_state_io(path, source))?, + }; let path_metadata = match open_recovery_file(path) { Ok(reopened) => reopened.metadata, Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), @@ -2132,7 +2141,8 @@ where return Err(recovery_path_changed(path)); } }; - if !same_recovery_file(&metadata, &final_metadata) + if !final_metadata.is_dir() + || !same_recovery_file(&metadata, &final_metadata) || !same_recovery_file(&metadata, &path_metadata) { return Err(recovery_path_changed(path)); @@ -2172,7 +2182,7 @@ fn recovery_path_changed(path: &Path) -> GitError { } struct RecoveryFile { - file: fs::File, + file: Option, metadata: fs::Metadata, } @@ -2184,13 +2194,19 @@ enum RecoveryOpenError { #[cfg(unix)] fn recovery_directory_children( - file: &fs::File, + file: Option<&fs::File>, path: &Path, deadline: Instant, entries: &usize, ) -> Result, GitError> { use rustix::fs::Dir; + let Some(file) = file else { + return Err(recovery_state_io( + path, + io::Error::other("recovery directory has no open descriptor"), + )); + }; let mut children = Vec::new(); let directory = Dir::read_from(file).map_err(|source| recovery_state_io(path, io::Error::from(source)))?; @@ -2213,7 +2229,7 @@ fn recovery_directory_children( #[cfg(not(unix))] fn recovery_directory_children( - _file: &fs::File, + _file: Option<&fs::File>, path: &Path, deadline: Instant, entries: &usize, @@ -2250,7 +2266,10 @@ fn open_recovery_file(path: &Path) -> Result { }; let file = fs::File::from(descriptor); let metadata = file.metadata().map_err(RecoveryOpenError::Io)?; - Ok(RecoveryFile { file, metadata }) + Ok(RecoveryFile { + file: Some(file), + metadata, + }) } #[cfg(not(unix))] @@ -2265,7 +2284,11 @@ fn open_recovery_file(path: &Path) -> Result { if metadata.file_type().is_symlink() { return Err(RecoveryOpenError::Symlink); } - let file = fs::File::open(path).map_err(RecoveryOpenError::Io)?; + let file = if metadata.is_dir() { + None + } else { + Some(fs::File::open(path).map_err(RecoveryOpenError::Io)?) + }; Ok(RecoveryFile { file, metadata }) } From 90ebf08ae49224ebf37fffbbc43f7e3e782c26ec Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 16:30:53 -0400 Subject: [PATCH 34/53] fix: close recovery concurrency boundaries --- .github/workflows/ci.yml | 4 +- Cargo.lock | 68 +-- crates/bitbygit-git/Cargo.toml | 1 - crates/bitbygit-git/src/lib.rs | 876 ++++++++++++++++++++++++--------- crates/bitbygit-tui/src/lib.rs | 47 +- docs/guardrails.md | 26 +- 6 files changed, 720 insertions(+), 302 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3401330..3eb0e92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,5 +67,5 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Verify fail-closed recovery execution - run: cargo test --locked -p bitbygit-git recovery_execution_capability + - name: Verify recovery platform boundary + run: cargo test --locked -p bitbygit-git recovery_platform_capability diff --git a/Cargo.lock b/Cargo.lock index f8c3c2a..5e783a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,8 @@ dependencies = [ name = "bitbygit-git" version = "0.1.0" dependencies = [ - "rustix 0.38.44", + "rustix", "sha2", - "tempfile", ] [[package]] @@ -131,7 +130,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -223,12 +222,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "fnv" version = "1.0.7" @@ -251,17 +244,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -350,12 +332,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "lock_api" version = "0.4.14" @@ -398,12 +374,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "parking_lot" version = "0.12.5" @@ -451,12 +421,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "ratatui" version = "0.29.0" @@ -496,23 +460,10 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -676,19 +627,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - [[package]] name = "toml" version = "0.8.23" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 8245654..3627fcd 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] sha2 = "0.10" -tempfile = "3" [target.'cfg(unix)'.dependencies] rustix = { version = "0.38", features = ["fs", "process"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 5edb5ef..41bfe43 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -21,6 +21,8 @@ use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(target_os = "linux")] +use rustix::fs::{FlockOperation, flock}; #[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, ftruncate, memfd_create}; #[cfg(unix)] @@ -47,6 +49,40 @@ const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; const RECOVERY_EXECUTION_CAPTURE_LIMIT: usize = 4 * 1024 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; +const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; +const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; +#[cfg(target_os = "linux")] +const RECOVERY_LOCK_NAME: &str = "bitbygit-recovery.lock"; +const RECOVERY_HOOKS: &[&str] = &[ + "applypatch-msg", + "commit-msg", + "fsmonitor-watchman", + "p4-changelist", + "p4-post-changelist", + "p4-pre-submit", + "p4-prepare-changelist", + "post-applypatch", + "post-checkout", + "post-commit", + "post-index-change", + "post-merge", + "post-receive", + "post-rewrite", + "post-update", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "pre-receive", + "prepare-commit-msg", + "proc-receive", + "push-to-checkout", + "reference-transaction", + "sendemail-validate", + "update", +]; const RECOVERY_CONTROL_PATHS: &[&str] = &[ "HEAD", "index", @@ -291,11 +327,19 @@ impl Git { ensure_recovery_execution_supported(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; self.validate_recovery_until(operation, action, deadline)?; - self.run_recovery_args_until(operation, action, deadline) + self.ensure_recovery_process_configuration_safe_until(deadline)?; + let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; + self.run_recovery_args_until_with(operation, action, deadline, || { + recovery_lock.ensure_identity(operation, action)?; + self.validate_recovery_until(operation, action, deadline) + }) } pub fn recovery_state(&self) -> Result { - self.recovery_state_until(Instant::now() + self.recovery_plan_timeout) + ensure_recovery_planning_supported()?; + let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.recovery_state_until(deadline) } pub fn prepare_recovery( @@ -303,7 +347,9 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, ) -> Result { + ensure_recovery_planning_supported()?; let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_process_configuration_safe_until(deadline)?; self.validate_recovery_until(operation, action, deadline)?; self.recovery_state_until(deadline) } @@ -313,11 +359,10 @@ impl Git { operation: RepositoryOperation, action: RecoveryAction, ) -> Result<(), GitError> { - self.validate_recovery_until( - operation, - action, - Instant::now() + self.recovery_plan_timeout, - ) + ensure_recovery_planning_supported()?; + let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.validate_recovery_until(operation, action, deadline) } pub fn recover_exact( @@ -340,10 +385,13 @@ impl Git { F: FnOnce() -> Result<(), GitError>, { ensure_recovery_execution_supported(operation, action)?; - self.validate_recovery_action(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; + self.validate_recovery_action(operation, action)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; + let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; self.run_recovery_args_until_with(operation, action, deadline, || { before_spawn_check()?; + recovery_lock.ensure_identity(operation, action)?; self.ensure_recovery_state(expected_state, operation, action, deadline) }) } @@ -1239,6 +1287,22 @@ impl Git { } } let root = self.repo_root_until(deadline)?; + let mut ignored_bytes = 0_u64; + for relative in self.ignored_worktree_paths_until(deadline)? { + let path = root.join(&relative); + if let Ok(metadata) = fs::metadata(&path) { + ignored_bytes = ignored_bytes.saturating_add(metadata.len()); + if ignored_bytes > RECOVERY_IGNORED_DATA_LIMIT { + return Err(GitError::Blocked { + message: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit" + .to_owned(), + }); + } + } + let mut label = b"ignored-worktree/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + hash_recovery_path(&mut hasher, &label, &path, deadline, &mut entries)?; + } for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); @@ -1256,6 +1320,181 @@ impl Git { }) } + fn ensure_recovery_process_configuration_safe_until( + &self, + deadline: Instant, + ) -> Result<(), GitError> { + if self.bounded_config_is_enabled("core.fsmonitor", deadline)? { + return Err(GitError::Blocked { + message: "recovery is blocked because core.fsmonitor may start an uncontained process; disable it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + if self.bounded_config_is_enabled("commit.gpgsign", deadline)? { + return Err(GitError::Blocked { + message: "recovery is blocked because commit.gpgsign may start an uncontained signing process; disable it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + let args = vec![ + "config".to_owned(), + "--name-only".to_owned(), + "--null".to_owned(), + "--get-regexp".to_owned(), + r"^(filter\..*\.(clean|smudge|process)|merge\..*\.driver)$".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.success() { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because external Git process configuration exceeds the bounded diagnostic limit" + .to_owned(), + }); + } + let names = output + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|name| !name.is_empty()) + .map(|name| String::from_utf8_lossy(name).into_owned()) + .collect::>(); + if self.recovery_uses_external_attributes_until(deadline)? { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because active external filter or merge-driver configuration may start uncontained processes: {}; disable it and preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } + } else if output.status.code() != Some(1) { + return Err(output.git_error(args)); + } + + let hooks = self.recovery_hooks_path_until(deadline)?; + let enabled_hooks = RECOVERY_HOOKS + .iter() + .filter(|hook| hook_is_enabled(&hooks.join(hook))) + .copied() + .collect::>(); + if !enabled_hooks.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because executable Git hooks may start uncontained processes: {}; disable them and preview recovery again, or run Git manually", + enabled_hooks.join(", ") + ), + }); + } + + for relative in [ + "rebase-merge/git-rebase-todo", + "rebase-apply/git-rebase-todo", + ] { + let path = self.git_path_until(relative, deadline)?; + let Some(contents) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + if contents.lines().any(|line| { + let line = line.trim_start(); + line.starts_with("exec ") || line.starts_with("x ") + }) { + return Err(GitError::Blocked { + message: "recovery is blocked because the remaining rebase plan contains an exec command that may start uncontained processes; remove it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + } + Ok(()) + } + + fn recovery_uses_external_attributes_until(&self, deadline: Instant) -> Result { + let root = self.repo_root_until(deadline)?; + let mut paths = self + .worktree_attributes_until(deadline)? + .into_iter() + .map(|path| root.join(path)) + .collect::>(); + paths.push(self.git_path_until("info/attributes", deadline)?); + for variable in ["GIT_ATTR_SYSTEM", "GIT_ATTR_GLOBAL"] { + if let Some(path) = self.git_var_path_until(variable, deadline)? { + paths.push(path); + } + } + for path in paths { + let Some(contents) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + if contents.split_ascii_whitespace().any(|attribute| { + attribute.starts_with("filter=") || attribute.starts_with("merge=") + }) { + return Ok(true); + } + } + Ok(false) + } + + fn bounded_config_is_enabled(&self, key: &str, deadline: Instant) -> Result { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.status.code() == Some(1) { + return Ok(false); + } + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {key} is too long"), + }); + } + let value = String::from_utf8_lossy(strip_byte_line_ending(&output.stdout.bytes)); + Ok(!matches!(value.trim(), "" | "0" | "false" | "no" | "off")) + } + + fn ignored_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { + let args = vec![ + "ls-files".to_owned(), + "--others".to_owned(), + "--ignored".to_owned(), + "--exclude-standard".to_owned(), + "--full-name".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because ignored worktree paths exceed the bounded output limit" + .to_owned(), + }); + } + let mut paths = BTreeSet::new(); + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid ignored worktree path" + .to_owned(), + }); + } + paths.insert(path); + } + Ok(paths.into_iter().collect()) + } + fn repository_operation_until( &self, deadline: Instant, @@ -1369,42 +1608,45 @@ impl Git { } fn worktree_attributes_until(&self, deadline: Instant) -> Result, GitError> { - let args = vec![ - "ls-files".to_owned(), - "--cached".to_owned(), - "--others".to_owned(), - "--ignored".to_owned(), - "--exclude-standard".to_owned(), - "-z".to_owned(), - "--".to_owned(), - ":(glob)**/.gitattributes".to_owned(), - ]; - let output = self.run_bounded_git(args.clone(), deadline, true)?; - if !output.status.success() { - return Err(output.git_error(args)); - } - if output.stdout.truncated { - return Err(GitError::Blocked { - message: "recovery planning is blocked because attribute paths exceed the bounded output limit" - .to_owned(), - }); - } let mut paths = BTreeSet::new(); - for path in output.stdout.bytes.split(|byte| *byte == 0) { - if path.is_empty() { - continue; + for selectors in [ + &["--cached", "--others", "--exclude-standard"][..], + &["--others", "--ignored", "--exclude-standard"][..], + ] { + let mut args = vec!["ls-files".to_owned()]; + args.extend(selectors.iter().map(|selector| (*selector).to_owned())); + args.extend([ + "-z".to_owned(), + "--".to_owned(), + ":(glob)**/.gitattributes".to_owned(), + ]); + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); } - let path = path_from_bytes(path); - if path.is_absolute() - || path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { + if output.stdout.truncated { return Err(GitError::Blocked { - message: "recovery planning is blocked by an invalid attribute path".to_owned(), + message: "recovery planning is blocked because attribute paths exceed the bounded output limit" + .to_owned(), }); } - paths.insert(path); + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid attribute path" + .to_owned(), + }); + } + paths.insert(path); + } } Ok(paths.into_iter().collect()) } @@ -1426,17 +1668,80 @@ impl Git { ))) } - fn run_args(&self, args: Vec) -> Result { - self.run_args_with_editor(args, false) + #[cfg(target_os = "linux")] + fn git_common_dir_until(&self, deadline: Instant) -> Result { + let args = vec![ + "rev-parse".to_owned(), + "--path-format=absolute".to_owned(), + "--git-common-dir".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because the common Git directory path is too long" + .to_owned(), + }); + } + Ok(path_from_bytes(strip_byte_line_ending( + &output.stdout.bytes, + ))) } - fn run_recovery_args_until( + #[cfg(target_os = "linux")] + fn acquire_recovery_lock_until( &self, operation: RepositoryOperation, action: RecoveryAction, deadline: Instant, - ) -> Result { - self.run_recovery_args_until_with(operation, action, deadline, || Ok(())) + ) -> Result { + let path = self + .git_common_dir_until(deadline)? + .join(RECOVERY_LOCK_NAME); + let descriptor = open( + &path, + OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_bits_truncate(0o600), + ) + .map_err(|source| recovery_lock_io(operation, action, source.into()))?; + flock(&descriptor, FlockOperation::NonBlockingLockExclusive).map_err(|source| { + if source == Errno::AGAIN { + GitError::Blocked { + message: format!( + "{} {} is blocked because another BitByGit recovery is active for this repository", + operation.label(), + action.label() + ), + } + } else { + recovery_lock_io(operation, action, source.into()) + } + })?; + let lock = RecoveryLock { + path, + file: fs::File::from(descriptor), + }; + lock.ensure_identity(operation, action)?; + Ok(lock) + } + + #[cfg(not(target_os = "linux"))] + fn acquire_recovery_lock_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + _deadline: Instant, + ) -> Result { + ensure_recovery_execution_supported(operation, action)?; + Err(GitError::Blocked { + message: "recovery execution is unavailable on this platform".to_owned(), + }) + } + + fn run_args(&self, args: Vec) -> Result { + self.run_args_with_editor(args, false) } fn run_recovery_args_until_with( @@ -1770,10 +2075,10 @@ where source, } })?)); - before_spawn()?; if Instant::now() >= deadline { return Err(GitError::TimedOut { args }); } + before_spawn()?; let mut child = command.spawn().map_err(|source| GitError::Io { args: args.clone(), source, @@ -1885,8 +2190,13 @@ fn bounded_capture_file(capacity: usize, args: &[String]) -> Result Result { - tempfile::tempfile().map_err(|source| recovery_output_io(args, source)) +fn bounded_capture_file(_capacity: usize, _args: &[String]) -> Result { + Err(GitError::Blocked { + message: format!( + "recovery planning and execution are blocked on {} because BitByGit cannot provide hard output and descendant-process bounds; inspect the repository and run the corresponding Git recovery command manually, or use BitByGit on Linux", + env::consts::OS + ), + }) } fn captured_file_position( @@ -1944,6 +2254,21 @@ fn ensure_recovery_execution_supported( Ok(()) } +#[cfg(target_os = "linux")] +fn ensure_recovery_planning_supported() -> Result<(), GitError> { + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_recovery_planning_supported() -> Result<(), GitError> { + Err(GitError::Blocked { + message: format!( + "recovery planning and execution are blocked on {} because BitByGit cannot provide hard output and descendant-process bounds; inspect the repository and run the corresponding Git recovery command manually, or use BitByGit on Linux", + env::consts::OS + ), + }) +} + #[cfg(not(target_os = "linux"))] fn ensure_recovery_execution_supported( operation: RepositoryOperation, @@ -1951,7 +2276,7 @@ fn ensure_recovery_execution_supported( ) -> Result<(), GitError> { Err(GitError::Blocked { message: format!( - "{} {} is blocked on {} because bitbygit cannot guarantee bounded Git output and descendant process cleanup on this platform; review the repository state and run `git {} --{}` manually, or use bitbygit on Linux", + "{} {} is blocked on {} because BitByGit cannot provide hard output and descendant-process bounds on this platform; inspect the repository state and run `git {} --{}` manually, or use BitByGit on Linux", operation.label(), action.label(), env::consts::OS, @@ -1961,6 +2286,98 @@ fn ensure_recovery_execution_supported( }) } +struct RecoveryLock { + #[cfg(target_os = "linux")] + path: PathBuf, + #[cfg(target_os = "linux")] + file: fs::File, +} + +impl RecoveryLock { + #[cfg(target_os = "linux")] + fn ensure_identity( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + let opened = self + .file + .metadata() + .map_err(|source| recovery_lock_io(operation, action, source))?; + let current = fs::symlink_metadata(&self.path) + .map_err(|source| recovery_lock_io(operation, action, source))?; + if !opened.is_file() + || opened.nlink() != 1 + || current.file_type().is_symlink() + || !same_recovery_file(&opened, ¤t) + { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }); + } + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + fn ensure_identity( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + ) -> Result<(), GitError> { + ensure_recovery_execution_supported(operation, action) + } +} + +#[cfg(target_os = "linux")] +fn recovery_lock_io( + operation: RepositoryOperation, + action: RecoveryAction, + source: io::Error, +) -> GitError { + GitError::Io { + args: vec![format!( + "{} {} BitByGit recovery lock", + operation.label(), + action.label() + )], + source, + } +} + +fn read_bounded_optional_file( + path: &Path, + limit: u64, + deadline: Instant, +) -> Result, GitError> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(recovery_state_io(path, source)), + }; + let mut contents = Vec::new(); + file.take(limit.saturating_add(1)) + .read_to_end(&mut contents) + .map_err(|source| recovery_state_io(path, source))?; + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-process-configuration".to_owned()], + }); + } + if contents.len() as u64 > limit { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} exceeds the bounded inspection limit", + path.display() + ), + }); + } + Ok(Some(String::from_utf8_lossy(&contents).into_owned())) +} + fn hash_recovery_path( hasher: &mut Sha256, label: &[u8], @@ -3670,7 +4087,7 @@ mod tests { #[cfg(unix)] #[test] - fn merge_continue_runs_hooks_and_does_not_bypass_signing() -> Result<(), Box> { + fn recovery_rejects_executable_hooks_without_starting_them() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; @@ -3686,60 +4103,19 @@ mod tests { fs::set_permissions(&commit_msg_hook, permissions)?; repo.run(["config", "core.hooksPath", "hooks"])?; - let signing_program = repo.path().join("signing-program"); - fs::write(&signing_program, "#!/bin/sh\ntouch signing-ran\nexit 1\n")?; - let mut permissions = fs::metadata(&signing_program)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&signing_program, permissions)?; - repo.run_args(&["config", "gpg.program", &signing_program.to_string_lossy()])?; - repo.run(["config", "commit.gpgsign", "true"])?; repo.write("conflict.txt", "resolved\n")?; repo.run(["add", "conflict.txt"])?; let git = Git::new(repo.path()); let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected configured signing failure".into()); + return Err("expected executable hook configuration to fail closed".into()); }; - let GitError::GitFailed { - args, - stdout, - stderr, - .. - } = error - else { - return Err("expected standard git merge failure".into()); - }; - assert_eq!(args, ["merge".to_owned(), "--continue".to_owned()]); - assert!(format!("{stdout}{stderr}").contains('\u{fffd}')); - assert!(repo.path().join("hook-ran").exists()); - assert!(repo.path().join("signing-ran").exists()); + assert!(error.to_string().contains("executable Git hooks")); + assert!(!repo.path().join("hook-ran").exists()); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } - #[cfg(unix)] - #[test] - fn successful_recovery_preserves_non_utf8_hook_output() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - let hook = repo.path().join(".git").join("hooks").join("commit-msg"); - fs::write(&hook, "#!/bin/sh\nprintf '\\377'\n")?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let git = Git::new(repo.path()); - - let output = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; - - assert!(format!("{}{}", output.stdout, output.stderr).contains('\u{fffd}')); - assert_eq!(git.status()?.operation, None); - repo.run(["rev-parse", "--verify", "HEAD^2"])?; - Ok(()) - } - #[test] fn recovery_fingerprint_tracks_refs_index_config_hooks_and_progress() -> Result<(), Box> { @@ -3759,13 +4135,6 @@ mod tests { let hook = repo.path().join(".git/hooks/commit-msg"); fs::write(&hook, "#!/bin/sh\nexit 0\n")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - } assert_ne!(git.recovery_state()?, baseline); fs::remove_file(hook)?; assert_eq!(git.recovery_state()?, baseline); @@ -3855,15 +4224,12 @@ mod tests { #[cfg(unix)] #[test] fn recovery_fingerprint_tracks_symlinked_hook_target_contents() -> Result<(), Box> { - use std::os::unix::fs::{PermissionsExt, symlink}; + use std::os::unix::fs::symlink; let (repo, _original_head) = prepare_merge_conflict()?; let external = TempRepo::new()?; let target = external.path().join("commit-msg-target"); fs::write(&target, "#!/bin/sh\nexit 0\n")?; - let mut permissions = fs::metadata(&target)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&target, permissions)?; symlink(&target, repo.path().join(".git/hooks/commit-msg"))?; let git = Git::new(repo.path()); let baseline = git.recovery_state()?; @@ -4059,6 +4425,75 @@ mod tests { Ok(()) } + #[test] + fn exact_rebase_abort_rejects_changed_ignored_collision() -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitignore", "target/\n")?; + repo.run(["add", ".gitignore"])?; + repo.run(["commit", "-m", "ignore target"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("first.txt", "first\n")?; + repo.run(["add", "first.txt"])?; + repo.run(["commit", "-m", "first"])?; + repo.write(".gitignore", "")?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "committed\n")?; + repo.run(["add", ".gitignore", "target/victim.bin"])?; + repo.run(["commit", "-m", "track victim"])?; + repo.run(["switch", "main"])?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "--exec", "false", "main"])?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + let remaining = fs::read_to_string(&todo)? + .lines() + .filter(|line| { + let line = line.trim_start(); + !line.starts_with("exec ") && !line.starts_with("x ") + }) + .collect::>() + .join("\n"); + fs::write(todo, remaining)?; + fs::create_dir(repo.path().join("target"))?; + repo.write("target/victim.bin", "at preview\n")?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + repo.write("target/victim.bin", "changed after preview\n")?; + + let Err(error) = git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + ) else { + return Err("expected changed ignored collision to block abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!( + fs::read_to_string(repo.path().join("target/victim.bin"))?, + "changed after preview\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + + #[test] + fn recovery_fingerprint_bounds_ignored_data() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write(".gitignore", "large.ignored\n")?; + let ignored = fs::File::create(repo.path().join("large.ignored"))?; + ignored.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; + + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected oversized ignored data to block planning".into()); + }; + + assert!(error.to_string().contains("ignored worktree data exceeds")); + Ok(()) + } + #[test] fn exact_recovery_detects_synchronized_change_at_spawn_boundary() -> Result<(), Box> { @@ -4132,7 +4567,7 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_planning_times_out_hanging_status_hook() -> Result<(), Box> { + fn recovery_planning_rejects_fsmonitor_before_it_runs() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; @@ -4150,165 +4585,164 @@ mod tests { ); let started = Instant::now(); + let marker = repo.path().join("fsmonitor-ran"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display()))?; let Err(error) = git.recovery_state() else { - return Err("expected hanging recovery planning hook to time out".into()); + return Err("expected fsmonitor recovery planning to fail closed".into()); }; - assert!(matches!(error, GitError::TimedOut { .. })); + assert!(error.to_string().contains("core.fsmonitor")); + assert!(!marker.exists()); assert!(started.elapsed() < Duration::from_secs(2)); Ok(()) } - #[cfg(unix)] #[test] - fn recovery_caps_noisy_hook_output() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - + fn recovery_rejects_active_external_filter_configuration() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - fs::write( - &hook, - "#!/bin/sh\ndd if=/dev/zero bs=1024 count=1024 2>/dev/null\n", - )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::with_recovery_limits( - repo.path(), - Duration::from_secs(10), - Duration::from_secs(5), - 4096, - ); + let marker = repo.path().join("filter-ran"); + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; + repo.run_args(&[ + "config", + "filter.unsafe.smudge", + &format!("touch '{}'", marker.display()), + ])?; - let output = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue)?; + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected external filter configuration to fail closed".into()); + }; - assert!( - format!("{}{}", output.stdout, output.stderr).contains("output truncated by bitbygit") - ); - assert!(output.stdout.len() < 5000); - assert!(output.stderr.len() < 5000); - assert_eq!(git.status()?.operation, None); + assert!(error.to_string().contains("active external filter")); + assert!(!marker.exists()); Ok(()) } - #[cfg(unix)] #[test] - fn recovery_times_out_quiet_hanging_hook() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let hook = repo.path().join(".git/hooks/commit-msg"); - fs::write(&hook, "#!/bin/sh\nsleep 30\n")?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::with_recovery_limits( - repo.path(), - Duration::from_secs(10), - Duration::from_millis(200), - 4096, - ); - let started = Instant::now(); + fn recovery_rejects_remaining_rebase_exec_command() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + fs::write(&todo, "exec touch must-not-run\n")?; - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected hanging recovery hook to time out".into()); + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected rebase exec command to fail closed".into()); }; - assert!(matches!(error, GitError::TimedOut { .. })); - assert!(started.elapsed() < Duration::from_secs(2)); - assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert!(error.to_string().contains("rebase plan contains an exec")); + assert!(!repo.path().join("must-not-run").exists()); Ok(()) } #[cfg(target_os = "linux")] #[test] - fn recovery_timeout_terminates_hook_descendants() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; + fn recovery_lock_serializes_bitbygit_execution() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let held = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected concurrent BitByGit recovery to be blocked".into()); + }; + assert!(error.to_string().contains("another BitByGit recovery")); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + + drop(held); + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + assert_eq!(git.status()?.operation, None); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_lock_rejects_replaced_lock_identity() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - repo.write("conflict.txt", "resolved\n")?; - repo.run(["add", "conflict.txt"])?; - let descendant_pid = repo.path().join("descendant.pid"); - let hook = repo.path().join(".git/hooks/commit-msg"); - fs::write( - &hook, - format!( - "#!/bin/sh\nsleep 30 &\nprintf '%s' $! > '{}'\nwait\n", - descendant_pid.display() - ), + let git = Git::new(repo.path()); + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), )?; - let mut permissions = fs::metadata(&hook)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&hook, permissions)?; - let git = Git::with_recovery_limits( - repo.path(), - Duration::from_secs(10), - Duration::from_millis(200), - 4096, - ); + let replaced = lock.path.with_extension("replaced"); + fs::rename(&lock.path, &replaced)?; + fs::write(&lock.path, "replacement\n")?; - let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { - return Err("expected hanging recovery hook to time out".into()); + let Err(error) = lock.ensure_identity(RepositoryOperation::Merge, RecoveryAction::Abort) + else { + return Err("expected replaced recovery lock to fail identity check".into()); }; - assert!(matches!(error, GitError::TimedOut { .. })); - let pid = fs::read_to_string(descendant_pid)?; - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline - && Command::new("kill") - .args(["-0", pid.trim()]) - .stderr(Stdio::null()) - .status()? - .success() - { - thread::sleep(Duration::from_millis(10)); - } - assert!( - !Command::new("kill") - .args(["-0", pid.trim()]) - .stderr(Stdio::null()) - .status()? - .success(), - "hook descendant {pid} survived recovery timeout" + assert!(error.to_string().contains("lock identity changed")); + drop(lock); + fs::remove_file(replaced)?; + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn exact_recovery_surfaces_standard_git_lock_failure() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let index_lock = git.git_path("index.lock")?; + fs::write(&index_lock, "external Git writer\n")?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected Git index lock failure".into()); + }; + + let GitError::GitFailed { args, stderr, .. } = error else { + return Err("expected standard Git failure to be surfaced".into()); + }; + assert_eq!(args, ["merge".to_owned(), "--abort".to_owned()]); + assert!(stderr.contains("index.lock")); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + fs::remove_file(index_lock)?; Ok(()) } #[cfg(target_os = "linux")] #[test] - fn recovery_execution_capability_is_available() -> Result<(), Box> { + fn recovery_platform_capability_is_available() -> Result<(), Box> { ensure_recovery_execution_supported(RepositoryOperation::Merge, RecoveryAction::Abort)?; Ok(()) } #[cfg(not(target_os = "linux"))] #[test] - fn recovery_execution_capability_fails_closed_after_planning() -> Result<(), Box> { + fn recovery_platform_capability_fails_closed_before_planning() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); - let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; - let Err(error) = - git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + let Err(error) = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort) else { - return Err("expected recovery execution to fail closed".into()); + return Err("expected recovery planning to fail closed".into()); }; let message = error.to_string(); - assert!(message.contains("cannot guarantee bounded Git output")); - assert!(message.contains("descendant process cleanup")); - assert!(message.contains("git merge --abort")); + assert!(message.contains("recovery planning and execution are blocked")); + assert!(message.contains("hard output and descendant-process bounds")); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } #[cfg(unix)] #[test] - fn recovery_does_not_wait_for_background_hook_pipe_holders() -> Result<(), Box> { + fn recovery_rejects_hooks_that_would_detach_descendants() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; @@ -4334,21 +4768,14 @@ mod tests { Duration::from_secs(5), 4096, ); - let started = Instant::now(); - - let result = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue); + let Err(error) = git.recover(RepositoryOperation::Merge, RecoveryAction::Continue) else { + return Err("expected detached hook to fail closed".into()); + }; - for pid_file in [&background_pid, &detached_pid] { - if let Ok(pid) = fs::read_to_string(pid_file) { - let _status = Command::new("kill") - .args(["-KILL", pid.trim()]) - .stderr(Stdio::null()) - .status(); - } - } - result?; - assert!(started.elapsed() < Duration::from_secs(2)); - assert_eq!(git.status()?.operation, None); + assert!(error.to_string().contains("executable Git hooks")); + assert!(!background_pid.exists()); + assert!(!detached_pid.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -5290,6 +5717,7 @@ mod tests { Ok(()) } + #[cfg(unix)] fn write_path( &self, relative_path: &PathBuf, diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index 4d9fad0..a141ee2 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -2007,6 +2007,16 @@ impl OperationPlanner { if requests.len() < 2 { return Err("Prompt sequence requires at least two steps.".to_owned()); } + if requests + .iter() + .skip(1) + .any(|request| matches!(request, OperationRequest::Recover(_))) + { + return Err( + "Recovery sequence blocked: recovery must be the first step so its confirmed state is captured by the sequence preview." + .to_owned(), + ); + } self.ensure_conflict_mode_allowed(&requests)?; for (index, request) in requests.iter().enumerate() { prompt_sequence_request_preview(request).map_err(|error| { @@ -5807,9 +5817,9 @@ mod tests { OperationRequest::Fetch, OperationRequest::Recover(RecoveryRequest::MergeContinue), ]) else { - return Err("fetch then blocked merge continue should fail sequence preflight".into()); + return Err("deferred recovery should fail sequence preflight".into()); }; - assert!(error.contains("resolve and stage all conflicts")); + assert!(error.contains("recovery must be the first step")); let operation = planner .plan_request(OperationRequest::Recover(RecoveryRequest::MergeAbort)) @@ -5840,7 +5850,7 @@ mod tests { ]) else { return Err("dependent recovery sequence should fail full preflight".into()); }; - assert!(error.contains("only one recovery action can be preflighted")); + assert!(error.contains("recovery must be the first step")); assert_eq!(git_stdout(&repo, &["rev-parse", "HEAD"])?, head_before); assert_eq!( Git::new(repo).status()?.operation, @@ -5849,6 +5859,37 @@ mod tests { Ok(()) } + #[test] + fn recovery_first_sequence_executes_the_state_captured_at_preview() -> Result<(), Box> + { + let repo = merge_conflict_repo("recovery-sequence-preview-state")?; + let sequence = OperationPlanner::new(&repo) + .plan_prompt_sequence(vec![ + OperationRequest::Recover(RecoveryRequest::MergeAbort), + OperationRequest::Branches, + ]) + .map_err(std::io::Error::other)?; + std::fs::write( + repo.join("conflict.txt"), + "changed after sequence preview\n", + )?; + let paths = isolated_store_paths("recovery-sequence-preview-state-audit")?; + + let result = PromptSequenceExecutor::with_audit_paths(&repo, paths.clone()) + .execute(sequence.sequence); + + assert!(result.message().contains("state changed after preview")); + assert!(!result.message().contains("Prompt sequence completed")); + assert_eq!( + Git::new(&repo).status()?.operation, + Some(RepositoryOperation::Merge) + ); + let entries = LocalStore::open(paths)?.list_audit_entries()?; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|entry| entry.operation == "merge_abort")); + Ok(()) + } + #[test] fn recovery_execution_revalidates_state_and_audits_safely() -> Result<(), Box> { let repo = merge_conflict_repo("recovery-revalidation")?; diff --git a/docs/guardrails.md b/docs/guardrails.md index 6829f2a..263182e 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -121,13 +121,25 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -BitByGit revalidates bounded recovery inputs immediately before invoking Git and -surfaces Git's own operation-state failures. This narrows but does not claim to -eliminate the cross-process scheduling window for external worktree writers. -Recovery planning remains available on all release platforms. Recovery execution -fails closed with a manual-command diagnostic on platforms where BitByGit cannot -guarantee both hard output bounds and descendant process cleanup; currently, -execution is supported on Linux. +On Linux, BitByGit fingerprints bounded tracked, untracked, ignored, control, +configuration, attribute, hook, and ref inputs at preview. It takes an +identity-checked lock in the common Git directory, checks that fingerprint as the +last operation before spawning Git, and holds the lock through execution. A +recovery action in a prompt sequence must be first so this state is captured by +the displayed sequence preview. + +The lock serializes BitByGit recovery processes for the repository. Other Git +clients and filesystem writers do not honor an application lock; their normal +concurrency boundary still applies, and BitByGit surfaces resulting Git failures +rather than claiming to exclude those writers. + +Recovery rejects executable hooks, active external filters or merge drivers, +fsmonitor and signing processes, and remaining rebase `exec` commands. This +prevents those configured processes from detaching outside bounded cleanup. +Planning also fails closed when its path, entry, ignored-data, or output limits +are exceeded. On non-Linux platforms, both recovery planning and execution fail +before Git is spawned, with a manual-command diagnostic, until equivalent hard +output and descendant-process bounds are available. ## Confirmation Copy From 6918205944deb67ac5a40b67b3444a7ad675bd1f Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 17:18:17 -0400 Subject: [PATCH 35/53] fix: close recovery process escapes --- crates/bitbygit-git/src/lib.rs | 397 ++++++++++++++++++++++++++++----- 1 file changed, 337 insertions(+), 60 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 41bfe43..b09f52a 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1287,21 +1287,20 @@ impl Git { } } let root = self.repo_root_until(deadline)?; - let mut ignored_bytes = 0_u64; + let mut ignored_bytes_remaining = Some(RECOVERY_IGNORED_DATA_LIMIT); for relative in self.ignored_worktree_paths_until(deadline)? { let path = root.join(&relative); - if let Ok(metadata) = fs::metadata(&path) { - ignored_bytes = ignored_bytes.saturating_add(metadata.len()); - if ignored_bytes > RECOVERY_IGNORED_DATA_LIMIT { - return Err(GitError::Blocked { - message: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit" - .to_owned(), - }); - } - } let mut label = b"ignored-worktree/".to_vec(); label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); - hash_recovery_path(&mut hasher, &label, &path, deadline, &mut entries)?; + let mut context = RecoveryHashContext { + deadline, + entries: &mut entries, + follow_symlinks: false, + byte_budget: &mut ignored_bytes_remaining, + }; + hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { + Ok(()) + })?; } for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); @@ -1330,11 +1329,25 @@ impl Git { .to_owned(), }); } - if self.bounded_config_is_enabled("commit.gpgsign", deadline)? { - return Err(GitError::Blocked { - message: "recovery is blocked because commit.gpgsign may start an uncontained signing process; disable it and preview recovery again, or run Git manually" - .to_owned(), - }); + for key in ["commit.gpgsign", "tag.gpgsign"] { + if self.bounded_config_is_enabled(key, deadline)? { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {key} may start an uncontained signing process; disable it and preview recovery again, or run Git manually" + ), + }); + } + } + for relative in ["rebase-merge/gpg_sign_opt", "rebase-apply/gpg_sign_opt"] { + let path = self.git_path_until(relative, deadline)?; + if read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + .is_some_and(|contents| !contents.trim().is_empty()) + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase requests commit signing with --gpg-sign, which may start an uncontained signer; abort and restart the rebase without signing, or run Git manually" + .to_owned(), + }); + } } let args = vec![ "config".to_owned(), @@ -1803,11 +1816,13 @@ impl Git { .env("SSH_ASKPASS", "") .env("SSH_ASKPASS_REQUIRE", "never") .env("GIT_EDITOR", "true") - .env("GIT_SEQUENCE_EDITOR", "true") - .args(&args); + .env("GIT_SEQUENCE_EDITOR", "true"); if optional_locks { command.env("GIT_OPTIONAL_LOCKS", "0"); + } else { + command.args(["-c", "maintenance.auto=false", "-c", "gc.auto=0"]); } + command.args(&args); if self.ssh_executable.is_some() || env::var_os("GIT_SSH_COMMAND").is_none() { let ssh_executable = self .ssh_executable @@ -2353,20 +2368,57 @@ fn read_bounded_optional_file( limit: u64, deadline: Instant, ) -> Result, GitError> { - let file = match fs::File::open(path) { - Ok(file) => file, - Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(source) => return Err(recovery_state_io(path, source)), + let opened = match open_recovery_file(path) { + Ok(opened) => opened, + Err(RecoveryOpenError::Missing) => return Ok(None), + Err(RecoveryOpenError::Symlink) => { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} is a symlink instead of a regular file; replace it with a regular file and preview recovery again, or run Git manually", + path.display() + ), + }); + } + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), }; - let mut contents = Vec::new(); - file.take(limit.saturating_add(1)) - .read_to_end(&mut contents) - .map_err(|source| recovery_state_io(path, source))?; - if Instant::now() >= deadline { - return Err(GitError::TimedOut { - args: vec!["recovery-process-configuration".to_owned()], + if !opened.metadata.is_file() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {} is not a regular file; replace it with a regular file and preview recovery again, or run Git manually", + path.display() + ), }); } + let Some(mut file) = opened.file else { + return Err(recovery_state_io( + path, + io::Error::other("regular recovery input has no open descriptor"), + )); + }; + let mut contents = Vec::new(); + let mut buffer = [0; 8192]; + while contents.len() as u64 <= limit { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-process-configuration".to_owned()], + }); + } + let requested = buffer.len().min( + limit + .saturating_add(1) + .saturating_sub(contents.len() as u64) as usize, + ); + if requested == 0 { + break; + } + let count = file + .read(&mut buffer[..requested]) + .map_err(|source| recovery_state_io(path, source))?; + if count == 0 { + break; + } + contents.extend_from_slice(&buffer[..count]); + } if contents.len() as u64 > limit { return Err(GitError::Blocked { message: format!( @@ -2375,6 +2427,21 @@ fn read_bounded_optional_file( ), }); } + let final_metadata = file + .metadata() + .map_err(|source| recovery_state_io(path, source))?; + let path_metadata = match open_recovery_file(path) { + Ok(reopened) => reopened.metadata, + Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !same_recovery_file(&opened.metadata, &final_metadata) + || !same_recovery_file(&opened.metadata, &path_metadata) + { + return Err(recovery_path_changed(path)); + } Ok(Some(String::from_utf8_lossy(&contents).into_owned())) } @@ -2399,22 +2466,35 @@ fn hash_recovery_path_with( where F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, { - hash_recovery_path_inner(hasher, label, path, deadline, entries, 0, after_contents) + let mut byte_budget = None; + let mut context = RecoveryHashContext { + deadline, + entries, + follow_symlinks: true, + byte_budget: &mut byte_budget, + }; + hash_recovery_path_inner(hasher, label, path, 0, &mut context, after_contents) +} + +struct RecoveryHashContext<'a> { + deadline: Instant, + entries: &'a mut usize, + follow_symlinks: bool, + byte_budget: &'a mut Option, } fn hash_recovery_path_inner( hasher: &mut Sha256, label: &[u8], path: &Path, - deadline: Instant, - entries: &mut usize, symlink_depth: usize, + context: &mut RecoveryHashContext<'_>, after_contents: &mut F, ) -> Result<(), GitError> where F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, { - ensure_recovery_fingerprint_capacity(deadline, entries)?; + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; hash_field(hasher, label); let opened = match open_recovery_file(path) { Ok(opened) => opened, @@ -2431,7 +2511,7 @@ where ), }); } - *entries += 1; + *context.entries += 1; hash_field(hasher, b"symlink"); let metadata = recovery_symlink_metadata(path)?; let target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; @@ -2440,23 +2520,24 @@ where return Err(recovery_path_changed(path)); } hash_field(hasher, target.as_os_str().as_encoded_bytes()); - ensure_recovery_fingerprint_capacity(deadline, entries)?; - let resolved_target = if target.is_absolute() { - target.clone() - } else { - path.parent() - .unwrap_or_else(|| Path::new(".")) - .join(&target) - }; - hash_recovery_path_inner( - hasher, - label, - &resolved_target, - deadline, - entries, - symlink_depth + 1, - after_contents, - )?; + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; + if context.follow_symlinks { + let resolved_target = if target.is_absolute() { + target.clone() + } else { + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(&target) + }; + hash_recovery_path_inner( + hasher, + label, + &resolved_target, + symlink_depth + 1, + context, + after_contents, + )?; + } after_contents(path, RecoveryPathKind::Symlink)?; let final_target = fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; @@ -2470,7 +2551,7 @@ where }; let mut file = opened.file; let metadata = opened.metadata; - *entries += 1; + *context.entries += 1; hash_field(hasher, &metadata.len().to_le_bytes()); #[cfg(unix)] hash_field(hasher, &metadata.mode().to_le_bytes()); @@ -2487,7 +2568,7 @@ where }; let mut buffer = [0; 64 * 1024]; loop { - if Instant::now() >= deadline { + if Instant::now() >= context.deadline { return Err(GitError::TimedOut { args: vec!["recovery-state".to_owned()], }); @@ -2498,6 +2579,15 @@ where if count == 0 { break; } + if let Some(remaining) = context.byte_budget.as_mut() { + let Some(updated) = remaining.checked_sub(count as u64) else { + return Err(GitError::Blocked { + message: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit" + .to_owned(), + }); + }; + *remaining = updated; + } hasher.update(&buffer[..count]); } let final_metadata = file @@ -2527,10 +2617,11 @@ where } } else if metadata.is_dir() { hash_field(hasher, b"directory"); - let mut children = recovery_directory_children(file.as_ref(), path, deadline, entries)?; + let mut children = + recovery_directory_children(file.as_ref(), path, context.deadline, context.entries)?; children.sort(); for child in children { - ensure_recovery_fingerprint_capacity(deadline, entries)?; + ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; let mut child_label = label.to_vec(); child_label.push(b'/'); child_label.extend_from_slice(child.as_encoded_bytes()); @@ -2538,9 +2629,8 @@ where hasher, &child_label, &path.join(child), - deadline, - entries, symlink_depth, + context, after_contents, )?; } @@ -2701,10 +2791,10 @@ fn open_recovery_file(path: &Path) -> Result { if metadata.file_type().is_symlink() { return Err(RecoveryOpenError::Symlink); } - let file = if metadata.is_dir() { - None - } else { + let file = if metadata.is_file() { Some(fs::File::open(path).map_err(RecoveryOpenError::Io)?) + } else { + None }; Ok(RecoveryFile { file, metadata }) } @@ -4494,6 +4584,27 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_fingerprint_does_not_follow_ignored_directory_symlinks() + -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let (repo, _original_head) = prepare_merge_conflict()?; + let external = TempRepo::new()?; + let external_data = fs::File::create(external.path().join("large.bin"))?; + external_data.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; + repo.write(".gitignore", "ignored-link\n")?; + symlink(external.path(), repo.path().join("ignored-link"))?; + let git = Git::new(repo.path()); + + let baseline = git.recovery_state()?; + fs::write(external.path().join("new-data"), "outside repository\n")?; + + assert_eq!(git.recovery_state()?, baseline); + Ok(()) + } + #[test] fn exact_recovery_detects_synchronized_change_at_spawn_boundary() -> Result<(), Box> { @@ -4597,6 +4708,60 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_fifo_rebase_todo_without_blocking() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + let todo = Git::new(repo.path()).git_path("rebase-merge/git-rebase-todo")?; + fs::remove_file(&todo)?; + assert!(Command::new("mkfifo").arg(&todo).status()?.success()); + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let Err(error) = git.recovery_state() else { + return Err("expected FIFO rebase todo to fail closed".into()); + }; + + assert!( + error.to_string().contains("not a regular file"), + "unexpected error: {error}" + ); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_fifo_attributes_without_blocking() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let attributes = Git::new(repo.path()).git_path("info/attributes")?; + assert!(Command::new("mkfifo").arg(&attributes).status()?.success()); + repo.run(["config", "filter.unsafe.smudge", "cat"])?; + let git = Git::with_recovery_limits( + repo.path(), + Duration::from_millis(200), + Duration::from_secs(5), + 4096, + ); + let started = Instant::now(); + + let Err(error) = git.recovery_state() else { + return Err("expected FIFO attributes to fail closed".into()); + }; + + assert!( + error.to_string().contains("not a regular file"), + "unexpected error: {error}" + ); + assert!(started.elapsed() < Duration::from_secs(2)); + Ok(()) + } + #[test] fn recovery_rejects_active_external_filter_configuration() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -4617,6 +4782,118 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_signing_configuration() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + + for key in ["commit.gpgsign", "tag.gpgsign"] { + repo.run_args(&["config", key, "true"])?; + let Err(error) = git.recovery_state() else { + return Err(format!("expected {key} to fail closed").into()); + }; + assert!(error.to_string().contains(key)); + repo.run_args(&["config", "--unset", key])?; + } + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_active_rebase_gpg_sign_before_starting_signer() -> Result<(), Box> + { + use std::os::unix::fs::PermissionsExt; + + let repo = initialized_repo()?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "topic"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run(["switch", "topic"])?; + let marker = repo.path().join("signer-ran"); + let signer = repo.path().join("fake-signer"); + fs::write( + &signer, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + )?; + let mut permissions = fs::metadata(&signer)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&signer, permissions)?; + repo.run_args(&["config", "gpg.program", &signer.to_string_lossy()])?; + repo.run_allow_failure(["rebase", "--gpg-sign", "main"])?; + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected active signed rebase to fail closed".into()); + }; + + assert!( + error + .to_string() + .contains("active rebase requests commit signing") + ); + assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn recovery_git_invocation_disables_automatic_maintenance() -> Result<(), Box> { + let repo = initialized_repo()?; + let git = Git::new(repo.path()); + + for (key, expected) in [ + ("maintenance.auto", &b"false\n"[..]), + ("gc.auto", &b"0\n"[..]), + ] { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = + git.run_bounded_git(args.clone(), Instant::now() + Duration::from_secs(2), false)?; + assert!(output.status.success(), "{key}"); + assert_eq!(output.stdout.bytes, expected, "{key}"); + } + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn bounded_command_timeout_kills_descendants() -> Result<(), Box> { + let repo = TempRepo::new()?; + let marker = repo.path().join("descendant-ran"); + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("(sleep 1; touch \"$1\") & wait") + .arg("sh") + .arg(&marker); + configure_process_group(&mut command); + + let Err(error) = run_bounded_command( + command, + vec!["descendant-timeout-test".to_owned()], + Instant::now() + Duration::from_millis(100), + 4096, + false, + || Ok(()), + ) else { + return Err("expected descendant command to time out".into()); + }; + assert!(matches!(error, GitError::TimedOut { .. })); + thread::sleep(Duration::from_millis(1100)); + assert!(!marker.exists()); + Ok(()) + } + #[test] fn recovery_rejects_remaining_rebase_exec_command() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; From c2137dfa2b78e5cb9d1897b98e09dd4951642ec9 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 17:48:47 -0400 Subject: [PATCH 36/53] fix: fail closed on recovery inputs --- crates/bitbygit-git/src/lib.rs | 298 +++++++++++++++++++++++++-------- crates/bitbygit-tui/src/lib.rs | 34 +++- 2 files changed, 252 insertions(+), 80 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index b09f52a..2943536 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -49,6 +49,7 @@ const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; const RECOVERY_EXECUTION_CAPTURE_LIMIT: usize = 4 * 1024 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; +const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; #[cfg(target_os = "linux")] @@ -114,6 +115,9 @@ pub struct Git { recovery_plan_timeout: Duration, recovery_execution_timeout: Duration, recovery_output_limit: usize, + isolated_test_config: bool, + #[cfg(test)] + test_global_config: Option, } impl Git { @@ -124,6 +128,9 @@ impl Git { recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, recovery_output_limit: RECOVERY_OUTPUT_LIMIT, + isolated_test_config: cfg!(test), + #[cfg(test)] + test_global_config: None, } } @@ -137,6 +144,9 @@ impl Git { recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, recovery_output_limit: RECOVERY_OUTPUT_LIMIT, + isolated_test_config: cfg!(test), + #[cfg(test)] + test_global_config: None, } } @@ -153,9 +163,24 @@ impl Git { recovery_plan_timeout: plan_timeout, recovery_execution_timeout: execution_timeout, recovery_output_limit: output_limit, + isolated_test_config: true, + test_global_config: None, } } + #[doc(hidden)] + pub fn with_isolated_test_config(mut self) -> Self { + self.isolated_test_config = true; + self + } + + #[cfg(test)] + fn with_test_global_config(mut self, path: impl Into) -> Self { + self.isolated_test_config = true; + self.test_global_config = Some(path.into()); + self + } + pub fn repository(&self) -> Result { let root = self.repo_root()?; let remotes = self.remotes()?; @@ -1287,6 +1312,22 @@ impl Git { } } let root = self.repo_root_until(deadline)?; + let mut untracked_bytes_remaining = Some(RECOVERY_UNTRACKED_DATA_LIMIT); + for relative in self.untracked_worktree_paths_until(deadline)? { + let path = root.join(&relative); + let mut label = b"untracked-worktree/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + let mut context = RecoveryHashContext { + deadline, + entries: &mut entries, + follow_symlinks: false, + byte_budget: &mut untracked_bytes_remaining, + byte_budget_error: "recovery planning is blocked because untracked worktree data exceeds the 64 MiB fingerprint limit", + }; + hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { + Ok(()) + })?; + } let mut ignored_bytes_remaining = Some(RECOVERY_IGNORED_DATA_LIMIT); for relative in self.ignored_worktree_paths_until(deadline)? { let path = root.join(&relative); @@ -1297,6 +1338,7 @@ impl Git { entries: &mut entries, follow_symlinks: false, byte_budget: &mut ignored_bytes_remaining, + byte_budget_error: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit", }; hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { Ok(()) @@ -1353,35 +1395,33 @@ impl Git { "config".to_owned(), "--name-only".to_owned(), "--null".to_owned(), - "--get-regexp".to_owned(), - r"^(filter\..*\.(clean|smudge|process)|merge\..*\.driver)$".to_owned(), + "--list".to_owned(), ]; let output = self.run_bounded_git(args.clone(), deadline, true)?; - if output.status.success() { - if output.stdout.truncated { - return Err(GitError::Blocked { - message: "recovery is blocked because external Git process configuration exceeds the bounded diagnostic limit" - .to_owned(), - }); - } - let names = output - .stdout - .bytes - .split(|byte| *byte == 0) - .filter(|name| !name.is_empty()) - .map(|name| String::from_utf8_lossy(name).into_owned()) - .collect::>(); - if self.recovery_uses_external_attributes_until(deadline)? { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because active external filter or merge-driver configuration may start uncontained processes: {}; disable it and preview recovery again, or run Git manually", - names.join(", ") - ), - }); - } - } else if output.status.code() != Some(1) { + if !output.status.success() { return Err(output.git_error(args)); } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" + .to_owned(), + }); + } + let names = output + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|name| is_recovery_external_driver_config(name)) + .map(|name| String::from_utf8_lossy(name).into_owned()) + .collect::>(); + if !names.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because configured external Git drivers may be activated by a recovery target tree and start uncontained processes: {}; remove them from every Git config scope, preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } let hooks = self.recovery_hooks_path_until(deadline)?; let enabled_hooks = RECOVERY_HOOKS @@ -1421,34 +1461,6 @@ impl Git { Ok(()) } - fn recovery_uses_external_attributes_until(&self, deadline: Instant) -> Result { - let root = self.repo_root_until(deadline)?; - let mut paths = self - .worktree_attributes_until(deadline)? - .into_iter() - .map(|path| root.join(path)) - .collect::>(); - paths.push(self.git_path_until("info/attributes", deadline)?); - for variable in ["GIT_ATTR_SYSTEM", "GIT_ATTR_GLOBAL"] { - if let Some(path) = self.git_var_path_until(variable, deadline)? { - paths.push(path); - } - } - for path in paths { - let Some(contents) = - read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? - else { - continue; - }; - if contents.split_ascii_whitespace().any(|attribute| { - attribute.starts_with("filter=") || attribute.starts_with("merge=") - }) { - return Ok(true); - } - } - Ok(false) - } - fn bounded_config_is_enabled(&self, key: &str, deadline: Instant) -> Result { let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; let output = self.run_bounded_git(args.clone(), deadline, true)?; @@ -1467,24 +1479,39 @@ impl Git { Ok(!matches!(value.trim(), "" | "0" | "false" | "no" | "off")) } + fn untracked_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { + self.other_worktree_paths_until(false, deadline) + } + fn ignored_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { - let args = vec![ - "ls-files".to_owned(), - "--others".to_owned(), - "--ignored".to_owned(), + self.other_worktree_paths_until(true, deadline) + } + + fn other_worktree_paths_until( + &self, + ignored: bool, + deadline: Instant, + ) -> Result, GitError> { + let kind = if ignored { "ignored" } else { "untracked" }; + let mut args = vec!["ls-files".to_owned(), "--others".to_owned()]; + if ignored { + args.push("--ignored".to_owned()); + } + args.extend([ "--exclude-standard".to_owned(), "--full-name".to_owned(), "-z".to_owned(), "--".to_owned(), - ]; + ]); let output = self.run_bounded_git(args.clone(), deadline, true)?; if !output.status.success() { return Err(output.git_error(args)); } if output.stdout.truncated { return Err(GitError::Blocked { - message: "recovery planning is blocked because ignored worktree paths exceed the bounded output limit" - .to_owned(), + message: format!( + "recovery planning is blocked because {kind} worktree paths exceed the bounded output limit" + ), }); } let mut paths = BTreeSet::new(); @@ -1499,8 +1526,9 @@ impl Git { .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(GitError::Blocked { - message: "recovery planning is blocked by an invalid ignored worktree path" - .to_owned(), + message: format!( + "recovery planning is blocked by an invalid {kind} worktree path" + ), }); } paths.insert(path); @@ -1817,6 +1845,14 @@ impl Git { .env("SSH_ASKPASS_REQUIRE", "never") .env("GIT_EDITOR", "true") .env("GIT_SEQUENCE_EDITOR", "true"); + if self.isolated_test_config { + let global_config = self.cwd.join(".bitbygit-test-global-config"); + #[cfg(test)] + let global_config = self.test_global_config.clone().unwrap_or(global_config); + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", global_config); + } if optional_locks { command.env("GIT_OPTIONAL_LOCKS", "0"); } else { @@ -2055,6 +2091,20 @@ impl Git { } } +fn is_recovery_external_driver_config(name: &[u8]) -> bool { + let name = String::from_utf8_lossy(name).to_ascii_lowercase(); + name == "diff.external" + || (name.starts_with("filter.") + && [".clean", ".smudge", ".process"] + .iter() + .any(|suffix| name.ends_with(suffix))) + || (name.starts_with("diff.") + && [".command", ".textconv"] + .iter() + .any(|suffix| name.ends_with(suffix))) + || (name.starts_with("merge.") && name.ends_with(".driver")) +} + fn run_bounded_command( mut command: Command, args: Vec, @@ -2472,6 +2522,7 @@ where entries, follow_symlinks: true, byte_budget: &mut byte_budget, + byte_budget_error: "recovery planning is blocked because recovery input data exceeds its fingerprint limit", }; hash_recovery_path_inner(hasher, label, path, 0, &mut context, after_contents) } @@ -2481,6 +2532,7 @@ struct RecoveryHashContext<'a> { entries: &'a mut usize, follow_symlinks: bool, byte_budget: &'a mut Option, + byte_budget_error: &'static str, } fn hash_recovery_path_inner( @@ -2582,8 +2634,7 @@ where if let Some(remaining) = context.byte_budget.as_mut() { let Some(updated) = remaining.checked_sub(count as u64) else { return Err(GitError::Blocked { - message: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit" - .to_owned(), + message: context.byte_budget_error.to_owned(), }); }; *remaining = updated; @@ -4569,6 +4620,47 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_changed_untracked_contents() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + repo.write("untracked.txt", "at preview\n")?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + repo.write("untracked.txt", "changed after preview\n")?; + + let Err(error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected changed untracked content to block abort".into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!( + fs::read_to_string(repo.path().join("untracked.txt"))?, + "changed after preview\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + + #[test] + fn recovery_fingerprint_bounds_untracked_data() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let untracked = fs::File::create(repo.path().join("large.untracked"))?; + untracked.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; + + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected oversized untracked data to block planning".into()); + }; + + assert!( + error + .to_string() + .contains("untracked worktree data exceeds") + ); + Ok(()) + } + #[test] fn recovery_fingerprint_bounds_ignored_data() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -4741,7 +4833,6 @@ mod tests { let (repo, _original_head) = prepare_merge_conflict()?; let attributes = Git::new(repo.path()).git_path("info/attributes")?; assert!(Command::new("mkfifo").arg(&attributes).status()?.success()); - repo.run(["config", "filter.unsafe.smudge", "cat"])?; let git = Git::with_recovery_limits( repo.path(), Duration::from_millis(200), @@ -4754,8 +4845,9 @@ mod tests { return Err("expected FIFO attributes to fail closed".into()); }; + let error = error.to_string(); assert!( - error.to_string().contains("not a regular file"), + error.contains("not a regular file") || error.contains("timed out"), "unexpected error: {error}" ); assert!(started.elapsed() < Duration::from_secs(2)); @@ -4763,22 +4855,84 @@ mod tests { } #[test] - fn recovery_rejects_active_external_filter_configuration() -> Result<(), Box> { + fn recovery_rejects_all_configured_external_driver_kinds() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - let marker = repo.path().join("filter-ran"); + let git = Git::new(repo.path()); + for key in [ + "filter.unsafe.clean", + "filter.unsafe.smudge", + "filter.unsafe.process", + "diff.external", + "diff.unsafe.command", + "diff.unsafe.textconv", + "merge.unsafe.driver", + ] { + repo.run(["config", key, "cat"])?; + let Err(error) = git.recovery_state() else { + return Err(format!("expected {key} to fail closed").into()); + }; + assert!(error.to_string().contains("external Git drivers")); + assert!(error.to_string().contains(key)); + repo.run(["config", "--unset", key])?; + } + Ok(()) + } + + #[test] + fn recovery_rejects_external_driver_from_global_scope() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let config_dir = TempRepo::new()?; + let global_config = config_dir.path().join("global-config"); + fs::write(&global_config, "[diff]\n\texternal = global-diff-command\n")?; + let git = Git::new(repo.path()).with_test_global_config(global_config); + + let Err(error) = git.recovery_state() else { + return Err("expected a global external diff driver to fail closed".into()); + }; + + assert!(error.to_string().contains("diff.external")); + assert!(error.to_string().contains("every Git config scope")); + Ok(()) + } + + #[test] + fn rebase_abort_rejects_filter_selected_only_by_target_tree() -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitattributes", "*.txt text\n")?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", ".gitattributes", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "topic"])?; repo.write(".gitattributes", "*.txt filter=unsafe\n")?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "topic"])?; + let target = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "main"])?; + repo.write(".gitattributes", "*.txt text\n")?; + assert_eq!( + repo.git_stdout_args(&["show", &format!("{target}:.gitattributes")])?, + "*.txt filter=unsafe\n" + ); + + let marker = repo.path().join("filter-ran"); repo.run_args(&[ "config", "filter.unsafe.smudge", &format!("touch '{}'", marker.display()), ])?; - - let Err(error) = Git::new(repo.path()).recovery_state() else { - return Err("expected external filter configuration to fail closed".into()); + let git = Git::new(repo.path()); + let Err(error) = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected target-tree filter configuration to fail closed".into()); }; - assert!(error.to_string().contains("active external filter")); + assert!(error.to_string().contains("recovery target tree")); assert!(!marker.exists()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) } diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index ea0ece2..f9d4145 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -2691,11 +2691,19 @@ impl OperationPlanner { } fn git(&self) -> Git { + let git = { + #[cfg(test)] + if let Some(executable) = &self.ssh_executable { + Git::with_ssh_executable(self.repo_root.clone(), executable) + } else { + Git::new(self.repo_root.clone()) + } + #[cfg(not(test))] + Git::new(self.repo_root.clone()) + }; #[cfg(test)] - if let Some(executable) = &self.ssh_executable { - return Git::with_ssh_executable(self.repo_root.clone(), executable); - } - Git::new(self.repo_root.clone()) + let git = git.with_isolated_test_config(); + git } fn github(&self, repository: &GitHubRepository) -> GitHub { @@ -3749,11 +3757,19 @@ impl PlanExecutor { } fn git(&self) -> Git { + let git = { + #[cfg(test)] + if let Some(executable) = &self.ssh_executable { + Git::with_ssh_executable(self.repo_root.clone(), executable) + } else { + Git::new(self.repo_root.clone()) + } + #[cfg(not(test))] + Git::new(self.repo_root.clone()) + }; #[cfg(test)] - if let Some(executable) = &self.ssh_executable { - return Git::with_ssh_executable(self.repo_root.clone(), executable); - } - Git::new(self.repo_root.clone()) + let git = git.with_isolated_test_config(); + git } fn audit_repo_id(&self) -> Option { @@ -4327,6 +4343,8 @@ impl PromptSequenceExecutor { let mut current_policy = self.load_policy(&self.policy); let git = Git::new(self.repo_root.clone()); + #[cfg(test)] + let git = git.with_isolated_test_config(); let requests = std::iter::once(first.plan.request.clone()) .chain(remaining_requests.iter().cloned()) .collect::>(); From 922a5b9c6db2ff63dfef6869221c38f7d94bfcae Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:03:41 -0400 Subject: [PATCH 37/53] fix: harden recovery serialization --- crates/bitbygit-git/src/lib.rs | 119 ++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 24 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 2943536..8f4472a 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -356,7 +356,8 @@ impl Git { let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; self.run_recovery_args_until_with(operation, action, deadline, || { recovery_lock.ensure_identity(operation, action)?; - self.validate_recovery_until(operation, action, deadline) + self.validate_recovery_until(operation, action, deadline)?; + recovery_lock.ensure_identity(operation, action) }) } @@ -417,7 +418,8 @@ impl Git { self.run_recovery_args_until_with(operation, action, deadline, || { before_spawn_check()?; recovery_lock.ensure_identity(operation, action)?; - self.ensure_recovery_state(expected_state, operation, action, deadline) + self.ensure_recovery_state(expected_state, operation, action, deadline)?; + recovery_lock.ensure_identity(operation, action) }) } @@ -1448,10 +1450,10 @@ impl Git { else { continue; }; - if contents.lines().any(|line| { - let line = line.trim_start(); - line.starts_with("exec ") || line.starts_with("x ") - }) { + if contents + .lines() + .any(|line| matches!(line.split_ascii_whitespace().next(), Some("exec" | "x"))) + { return Err(GitError::Blocked { message: "recovery is blocked because the remaining rebase plan contains an exec command that may start uncontained processes; remove it and preview recovery again, or run Git manually" .to_owned(), @@ -1738,31 +1740,31 @@ impl Git { action: RecoveryAction, deadline: Instant, ) -> Result { - let path = self - .git_common_dir_until(deadline)? - .join(RECOVERY_LOCK_NAME); + let common_dir = self.git_common_dir_until(deadline)?; + let common_dir_descriptor = open( + &common_dir, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map_err(|source| recovery_lock_io(operation, action, source.into()))?; + flock( + &common_dir_descriptor, + FlockOperation::NonBlockingLockExclusive, + ) + .map_err(|source| recovery_lock_error(operation, action, source))?; + let path = common_dir.join(RECOVERY_LOCK_NAME); let descriptor = open( &path, OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::from_bits_truncate(0o600), ) .map_err(|source| recovery_lock_io(operation, action, source.into()))?; - flock(&descriptor, FlockOperation::NonBlockingLockExclusive).map_err(|source| { - if source == Errno::AGAIN { - GitError::Blocked { - message: format!( - "{} {} is blocked because another BitByGit recovery is active for this repository", - operation.label(), - action.label() - ), - } - } else { - recovery_lock_io(operation, action, source.into()) - } - })?; + flock(&descriptor, FlockOperation::NonBlockingLockExclusive) + .map_err(|source| recovery_lock_error(operation, action, source))?; let lock = RecoveryLock { path, file: fs::File::from(descriptor), + common_dir: fs::File::from(common_dir_descriptor), }; lock.ensure_identity(operation, action)?; Ok(lock) @@ -2356,6 +2358,8 @@ struct RecoveryLock { path: PathBuf, #[cfg(target_os = "linux")] file: fs::File, + #[cfg(target_os = "linux")] + common_dir: fs::File, } impl RecoveryLock { @@ -2369,9 +2373,14 @@ impl RecoveryLock { .file .metadata() .map_err(|source| recovery_lock_io(operation, action, source))?; + let common_dir = self + .common_dir + .metadata() + .map_err(|source| recovery_lock_io(operation, action, source))?; let current = fs::symlink_metadata(&self.path) .map_err(|source| recovery_lock_io(operation, action, source))?; - if !opened.is_file() + if !common_dir.is_dir() + || !opened.is_file() || opened.nlink() != 1 || current.file_type().is_symlink() || !same_recovery_file(&opened, ¤t) @@ -2397,6 +2406,25 @@ impl RecoveryLock { } } +#[cfg(target_os = "linux")] +fn recovery_lock_error( + operation: RepositoryOperation, + action: RecoveryAction, + source: Errno, +) -> GitError { + if source == Errno::AGAIN { + GitError::Blocked { + message: format!( + "{} {} is blocked because another BitByGit recovery is active for this repository", + operation.label(), + action.label() + ), + } + } else { + recovery_lock_io(operation, action, source.into()) + } +} + #[cfg(target_os = "linux")] fn recovery_lock_io( operation: RepositoryOperation, @@ -5063,6 +5091,31 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn recovery_rejects_tab_delimited_rebase_exec_commands_before_spawn() + -> Result<(), Box> { + for (index, command) in ["exec", "x"].into_iter().enumerate() { + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let todo = git.git_path("rebase-merge/git-rebase-todo")?; + let marker = repo.path().join(format!("tab-exec-{index}-ran")); + fs::write(&todo, format!("{command}\ttouch '{}'\n", marker.display()))?; + + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) + else { + return Err(format!("expected tab-delimited {command} to fail closed").into()); + }; + + assert!(error.to_string().contains("rebase plan contains an exec")); + assert!(!marker.exists(), "{command} process started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + } + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn recovery_lock_serializes_bitbygit_execution() -> Result<(), Box> { @@ -5094,18 +5147,36 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn recovery_lock_rejects_replaced_lock_identity() -> Result<(), Box> { + fn recovery_lock_replacement_cannot_enable_concurrent_recovery() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; let lock = git.acquire_recovery_lock_until( RepositoryOperation::Merge, RecoveryAction::Abort, Instant::now() + Duration::from_secs(2), )?; + git.ensure_recovery_state( + &expected, + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; let replaced = lock.path.with_extension("replaced"); fs::rename(&lock.path, &replaced)?; fs::write(&lock.path, "replacement\n")?; + let Err(concurrent_error) = + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) + else { + return Err("expected stable repository lock to block concurrent recovery".into()); + }; + assert!( + concurrent_error + .to_string() + .contains("another BitByGit recovery") + ); + let Err(error) = lock.ensure_identity(RepositoryOperation::Merge, RecoveryAction::Abort) else { return Err("expected replaced recovery lock to fail identity check".into()); From 6cd90657113075e482f73a0fd5ec66a89af52114 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:33:01 -0400 Subject: [PATCH 38/53] fix: close recovery platform gaps --- .github/workflows/ci.yml | 7 +- Cargo.lock | 101 +++++- crates/bitbygit-git/Cargo.toml | 5 + crates/bitbygit-git/src/lib.rs | 551 ++++++++++++++++++++++++++------- docs/guardrails.md | 23 +- 5 files changed, 561 insertions(+), 126 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb0e92..9814cc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,5 +67,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Verify recovery platform boundary - run: cargo test --locked -p bitbygit-git recovery_platform_capability + - name: Test recovery support + run: cargo test --locked -p bitbygit-git recovery_ + + - name: Build workspace + run: cargo build --locked --workspace diff --git a/Cargo.lock b/Cargo.lock index 5e783a1..b8a9ffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,8 +37,11 @@ dependencies = [ name = "bitbygit-git" version = "0.1.0" dependencies = [ - "rustix", + "command-group", + "fs2", + "rustix 0.38.44", "sha2", + "tempfile", ] [[package]] @@ -97,6 +100,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "command-group" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "compact_str" version = "0.8.2" @@ -130,7 +143,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -222,6 +235,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fnv" version = "1.0.7" @@ -234,6 +253,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -244,6 +273,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -332,6 +372,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -374,6 +420,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -421,6 +484,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ratatui" version = "0.29.0" @@ -460,10 +529,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -627,6 +709,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 3627fcd..bcf4216 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -10,7 +10,12 @@ rust-version.workspace = true workspace = true [dependencies] +fs2 = "0.4" sha2 = "0.10" +tempfile = "3.20" [target.'cfg(unix)'.dependencies] rustix = { version = "0.38", features = ["fs", "process"] } + +[target.'cfg(windows)'.dependencies] +command-group = "5.0" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 8f4472a..4a2ad44 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -6,14 +6,20 @@ use std::fmt::{self, Display, Formatter}; use std::fs; use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Stdio}; +use std::process::{Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; use std::time::{Duration, Instant}; +use fs2::FileExt; use sha2::{Digest, Sha256}; +#[cfg(windows)] +use command_group::{CommandGroup, GroupChild}; +#[cfg(unix)] +use std::process::Child; + #[cfg(unix)] use std::os::unix::ffi::OsStringExt; #[cfg(unix)] @@ -21,8 +27,6 @@ use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::process::CommandExt; -#[cfg(target_os = "linux")] -use rustix::fs::{FlockOperation, flock}; #[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, ftruncate, memfd_create}; #[cfg(unix)] @@ -52,8 +56,9 @@ const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; -#[cfg(target_os = "linux")] -const RECOVERY_LOCK_NAME: &str = "bitbygit-recovery.lock"; +const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; +const RECOVERY_BUILT_IN_MERGE_STRATEGIES: &[&str] = + &["octopus", "ort", "ours", "recursive", "resolve", "subtree"]; const RECOVERY_HOOKS: &[&str] = &[ "applypatch-msg", "commit-msg", @@ -118,6 +123,10 @@ pub struct Git { isolated_test_config: bool, #[cfg(test)] test_global_config: Option, + #[cfg(all(test, unix))] + test_git_exec_path: Option, + #[cfg(test)] + recovery_data_dir: Option, } impl Git { @@ -131,6 +140,10 @@ impl Git { isolated_test_config: cfg!(test), #[cfg(test)] test_global_config: None, + #[cfg(all(test, unix))] + test_git_exec_path: None, + #[cfg(test)] + recovery_data_dir: None, } } @@ -147,6 +160,10 @@ impl Git { isolated_test_config: cfg!(test), #[cfg(test)] test_global_config: None, + #[cfg(all(test, unix))] + test_git_exec_path: None, + #[cfg(test)] + recovery_data_dir: None, } } @@ -165,6 +182,9 @@ impl Git { recovery_output_limit: output_limit, isolated_test_config: true, test_global_config: None, + #[cfg(unix)] + test_git_exec_path: None, + recovery_data_dir: None, } } @@ -181,6 +201,18 @@ impl Git { self } + #[cfg(all(test, unix))] + fn with_test_git_exec_path(mut self, path: impl Into) -> Self { + self.test_git_exec_path = Some(path.into()); + self + } + + #[cfg(test)] + fn with_recovery_data_dir(mut self, path: impl Into) -> Self { + self.recovery_data_dir = Some(path.into()); + self + } + pub fn repository(&self) -> Result { let root = self.repo_root()?; let remotes = self.remotes()?; @@ -355,9 +387,11 @@ impl Git { self.ensure_recovery_process_configuration_safe_until(deadline)?; let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; self.run_recovery_args_until_with(operation, action, deadline, || { - recovery_lock.ensure_identity(operation, action)?; + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action)?; self.validate_recovery_until(operation, action, deadline)?; - recovery_lock.ensure_identity(operation, action) + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) }) } @@ -417,9 +451,11 @@ impl Git { let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; self.run_recovery_args_until_with(operation, action, deadline, || { before_spawn_check()?; - recovery_lock.ensure_identity(operation, action)?; + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action)?; self.ensure_recovery_state(expected_state, operation, action, deadline)?; - recovery_lock.ensure_identity(operation, action) + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) }) } @@ -1393,6 +1429,22 @@ impl Git { }); } } + for relative in ["rebase-merge/strategy", "rebase-apply/strategy"] { + let path = self.git_path_until(relative, deadline)?; + let Some(strategy) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + let strategy = strategy.trim(); + if !strategy.is_empty() && !RECOVERY_BUILT_IN_MERGE_STRATEGIES.contains(&strategy) { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because the active rebase requests non-built-in merge strategy {strategy:?}, which may start an uncontained executable; abort and restart with a built-in strategy, or run Git manually" + ), + }); + } + } let args = vec![ "config".to_owned(), "--name-only".to_owned(), @@ -1711,76 +1763,74 @@ impl Git { ))) } - #[cfg(target_os = "linux")] - fn git_common_dir_until(&self, deadline: Instant) -> Result { - let args = vec![ - "rev-parse".to_owned(), - "--path-format=absolute".to_owned(), - "--git-common-dir".to_owned(), - ]; - let output = self.run_bounded_git(args.clone(), deadline, true)?; - if !output.status.success() { - return Err(output.git_error(args)); - } - if output.stdout.truncated { - return Err(GitError::Blocked { - message: "recovery is blocked because the common Git directory path is too long" - .to_owned(), - }); - } - Ok(path_from_bytes(strip_byte_line_ending( - &output.stdout.bytes, - ))) - } - - #[cfg(target_os = "linux")] fn acquire_recovery_lock_until( &self, operation: RepositoryOperation, action: RecoveryAction, deadline: Instant, ) -> Result { - let common_dir = self.git_common_dir_until(deadline)?; - let common_dir_descriptor = open( - &common_dir, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, - Mode::empty(), - ) - .map_err(|source| recovery_lock_io(operation, action, source.into()))?; - flock( - &common_dir_descriptor, - FlockOperation::NonBlockingLockExclusive, - ) - .map_err(|source| recovery_lock_error(operation, action, source))?; - let path = common_dir.join(RECOVERY_LOCK_NAME); - let descriptor = open( - &path, - OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, - Mode::from_bits_truncate(0o600), - ) - .map_err(|source| recovery_lock_io(operation, action, source.into()))?; - flock(&descriptor, FlockOperation::NonBlockingLockExclusive) + let repository_root = self.canonical_repository_root_until(deadline)?; + let path = self.recovery_lock_path(operation, action, &repository_root)?; + let guard_path = path.with_extension("guard"); + let guard = open_recovery_lock_file(&guard_path, operation, action)?; + guard + .try_lock_exclusive() + .map_err(|source| recovery_lock_error(operation, action, source))?; + let file = open_recovery_lock_file(&path, operation, action)?; + file.try_lock_exclusive() .map_err(|source| recovery_lock_error(operation, action, source))?; let lock = RecoveryLock { path, - file: fs::File::from(descriptor), - common_dir: fs::File::from(common_dir_descriptor), + file, + guard_path, + guard, + repository_root, }; - lock.ensure_identity(operation, action)?; + lock.ensure_identity(&lock.repository_root, operation, action)?; Ok(lock) } - #[cfg(not(target_os = "linux"))] - fn acquire_recovery_lock_until( + fn canonical_repository_root_until(&self, deadline: Instant) -> Result { + let root = self.repo_root_until(deadline)?; + fs::canonicalize(&root).map_err(|source| GitError::Io { + args: vec!["canonicalize recovery repository identity".to_owned()], + source, + }) + } + + fn recovery_lock_path( &self, operation: RepositoryOperation, action: RecoveryAction, - _deadline: Instant, - ) -> Result { - ensure_recovery_execution_supported(operation, action)?; - Err(GitError::Blocked { - message: "recovery execution is unavailable on this platform".to_owned(), - }) + repository_root: &Path, + ) -> Result { + #[cfg(test)] + let data_dir = self + .recovery_data_dir + .clone() + .or_else(|| Some(self.cwd.join(".git").join("bitbygit-test-data"))); + #[cfg(not(test))] + let data_dir = resolve_recovery_data_dir(|name| env::var_os(name).map(PathBuf::from)); + let data_dir = data_dir.ok_or_else(|| GitError::Blocked { + message: + "recovery is blocked because BitByGit's application data directory is unavailable" + .to_owned(), + })?; + let lock_dir = data_dir.join(RECOVERY_LOCK_DIRECTORY); + fs::create_dir_all(&lock_dir) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let lock_dir = fs::canonicalize(&lock_dir) + .map_err(|source| recovery_lock_io(operation, action, source))?; + if !lock_dir.is_dir() { + return Err(GitError::Blocked { + message: + "recovery is blocked because BitByGit's recovery lock path is not a directory" + .to_owned(), + }); + } + let key = recovery_repository_key(repository_root); + debug_assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit())); + Ok(lock_dir.join(format!("{key}.lock"))) } fn run_args(&self, args: Vec) -> Result { @@ -1855,6 +1905,10 @@ impl Git { .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", global_config); } + #[cfg(all(test, unix))] + if let Some(path) = &self.test_git_exec_path { + command.env("GIT_EXEC_PATH", path); + } if optional_locks { command.env("GIT_OPTIONAL_LOCKS", "0"); } else { @@ -2146,12 +2200,17 @@ where return Err(GitError::TimedOut { args }); } before_spawn()?; - let mut child = command.spawn().map_err(|source| GitError::Io { + let mut child = spawn_contained(command).map_err(|source| GitError::Io { args: args.clone(), source, })?; let status = loop { + if let Err(error) = ensure_capture_limits(&mut stdout, &mut stderr, capture_limit, &args) { + kill_process_tree(&mut child); + let _result = child.wait(); + return Err(error); + } if let Some(status) = child.try_wait().map_err(|source| GitError::Io { args: args.clone(), source, @@ -2239,6 +2298,17 @@ fn read_captured_file( }) } +fn ensure_capture_limits( + stdout: &mut fs::File, + stderr: &mut fs::File, + capacity: usize, + args: &[String], +) -> Result<(), GitError> { + let _stdout = captured_file_position(stdout, capacity, args)?; + let _stderr = captured_file_position(stderr, capacity, args)?; + Ok(()) +} + #[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] fn bounded_capture_file(capacity: usize, args: &[String]) -> Result { let descriptor = memfd_create( @@ -2257,13 +2327,8 @@ fn bounded_capture_file(capacity: usize, args: &[String]) -> Result Result { - Err(GitError::Blocked { - message: format!( - "recovery planning and execution are blocked on {} because BitByGit cannot provide hard output and descendant-process bounds; inspect the repository and run the corresponding Git recovery command manually, or use BitByGit on Linux", - env::consts::OS - ), - }) +fn bounded_capture_file(_capacity: usize, args: &[String]) -> Result { + tempfile::tempfile().map_err(|source| recovery_output_io(args, source)) } fn captured_file_position( @@ -2300,6 +2365,21 @@ fn configure_process_group(command: &mut Command) { #[cfg(not(unix))] fn configure_process_group(_command: &mut Command) {} +#[cfg(unix)] +fn spawn_contained(mut command: Command) -> io::Result { + command.spawn() +} + +#[cfg(windows)] +fn spawn_contained(mut command: Command) -> io::Result { + command.group().kill_on_drop(true).spawn() +} + +#[cfg(not(any(unix, windows)))] +fn spawn_contained(mut command: Command) -> io::Result { + command.spawn() +} + #[cfg(unix)] fn kill_process_tree(child: &mut Child) { if let Some(pid) = Pid::from_raw(child.id() as i32) { @@ -2308,12 +2388,17 @@ fn kill_process_tree(child: &mut Child) { let _result = child.kill(); } -#[cfg(not(unix))] -fn kill_process_tree(child: &mut Child) { +#[cfg(windows)] +fn kill_process_tree(child: &mut GroupChild) { let _result = child.kill(); } -#[cfg(target_os = "linux")] +#[cfg(not(any(unix, windows)))] +fn kill_process_tree(child: &mut std::process::Child) { + let _result = child.kill(); +} + +#[cfg(any(unix, windows))] fn ensure_recovery_execution_supported( _operation: RepositoryOperation, _action: RecoveryAction, @@ -2321,12 +2406,12 @@ fn ensure_recovery_execution_supported( Ok(()) } -#[cfg(target_os = "linux")] +#[cfg(any(unix, windows))] fn ensure_recovery_planning_supported() -> Result<(), GitError> { Ok(()) } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(unix, windows)))] fn ensure_recovery_planning_supported() -> Result<(), GitError> { Err(GitError::Blocked { message: format!( @@ -2336,7 +2421,7 @@ fn ensure_recovery_planning_supported() -> Result<(), GitError> { }) } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(unix, windows)))] fn ensure_recovery_execution_supported( operation: RepositoryOperation, action: RecoveryAction, @@ -2354,18 +2439,17 @@ fn ensure_recovery_execution_supported( } struct RecoveryLock { - #[cfg(target_os = "linux")] path: PathBuf, - #[cfg(target_os = "linux")] file: fs::File, - #[cfg(target_os = "linux")] - common_dir: fs::File, + guard_path: PathBuf, + guard: fs::File, + repository_root: PathBuf, } impl RecoveryLock { - #[cfg(target_os = "linux")] fn ensure_identity( &self, + current_repository_root: &Path, operation: RepositoryOperation, action: RecoveryAction, ) -> Result<(), GitError> { @@ -2373,17 +2457,25 @@ impl RecoveryLock { .file .metadata() .map_err(|source| recovery_lock_io(operation, action, source))?; - let common_dir = self - .common_dir + let current = fs::symlink_metadata(&self.path) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let opened_guard = self + .guard .metadata() .map_err(|source| recovery_lock_io(operation, action, source))?; - let current = fs::symlink_metadata(&self.path) + let current_guard = fs::symlink_metadata(&self.guard_path) .map_err(|source| recovery_lock_io(operation, action, source))?; - if !common_dir.is_dir() + if current_repository_root != self.repository_root || !opened.is_file() - || opened.nlink() != 1 + || !current.is_file() || current.file_type().is_symlink() || !same_recovery_file(&opened, ¤t) + || !opened_guard.is_file() + || !current_guard.is_file() + || current_guard.file_type().is_symlink() + || !same_recovery_file(&opened_guard, ¤t_guard) + || recovery_repository_key(current_repository_root) + != recovery_lock_key_from_path(&self.path).unwrap_or_default() { return Err(GitError::Blocked { message: format!( @@ -2393,26 +2485,65 @@ impl RecoveryLock { ), }); } + #[cfg(unix)] + if opened.nlink() != 1 || opened_guard.nlink() != 1 { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }); + } Ok(()) } +} - #[cfg(not(target_os = "linux"))] - fn ensure_identity( - &self, - operation: RepositoryOperation, - action: RecoveryAction, - ) -> Result<(), GitError> { - ensure_recovery_execution_supported(operation, action) +#[cfg(unix)] +fn open_recovery_lock_file( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + open( + path, + OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_bits_truncate(0o600), + ) + .map(fs::File::from) + .map_err(|source| recovery_lock_io(operation, action, source.into())) +} + +#[cfg(not(unix))] +fn open_recovery_lock_file( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because a BitByGit recovery lock path is a symlink", + operation.label(), + action.label() + ), + }); } + fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .map_err(|source| recovery_lock_io(operation, action, source)) } -#[cfg(target_os = "linux")] fn recovery_lock_error( operation: RepositoryOperation, action: RecoveryAction, - source: Errno, + source: io::Error, ) -> GitError { - if source == Errno::AGAIN { + if source.kind() == io::ErrorKind::WouldBlock { GitError::Blocked { message: format!( "{} {} is blocked because another BitByGit recovery is active for this repository", @@ -2421,11 +2552,10 @@ fn recovery_lock_error( ), } } else { - recovery_lock_io(operation, action, source.into()) + recovery_lock_io(operation, action, source) } } -#[cfg(target_os = "linux")] fn recovery_lock_io( operation: RepositoryOperation, action: RecoveryAction, @@ -2441,6 +2571,24 @@ fn recovery_lock_io( } } +fn resolve_recovery_data_dir(mut env: impl FnMut(&str) -> Option) -> Option { + env("BITBYGIT_DATA_DIR") + .or_else(|| env("XDG_DATA_HOME").map(|path| path.join("bitbygit"))) + .or_else(|| env("APPDATA").map(|path| path.join("bitbygit").join("data"))) + .or_else(|| env("HOME").map(|path| path.join(".local").join("share").join("bitbygit"))) +} + +fn recovery_repository_key(repository_root: &Path) -> String { + let digest = Sha256::digest(repository_root.as_os_str().as_encoded_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn recovery_lock_key_from_path(path: &Path) -> Option<&str> { + let name = path.file_name()?.to_str()?; + let key = name.strip_suffix(".lock")?; + (key.len() == 64 && key.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(key) +} + fn read_bounded_optional_file( path: &Path, limit: u64, @@ -2890,7 +3038,15 @@ fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { && left.ctime_nsec() == right.ctime_nsec() } -#[cfg(not(unix))] +#[cfg(windows)] +fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.is_file() == right.is_file() + && left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +#[cfg(not(any(unix, windows)))] fn same_recovery_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { left.is_file() == right.is_file() && left.len() == right.len() @@ -4542,6 +4698,38 @@ mod tests { Ok(()) } + #[cfg(any(target_os = "macos", windows))] + #[test] + fn recovery_command_output_is_bounded() -> Result<(), Box> { + #[cfg(unix)] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "while :; do printf '0123456789'; done"]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "for /L %i in (1,1,10000) do @echo 0123456789"]); + command + }; + configure_process_group(&mut command); + + let Err(error) = run_bounded_command( + command, + vec!["recovery-output-bound-test".to_owned()], + Instant::now() + Duration::from_secs(5), + 1024, + false, + || Ok(()), + ) else { + return Err("expected oversized recovery output to be stopped".into()); + }; + + assert!(error.to_string().contains("bounded capture limit")); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; @@ -5047,9 +5235,9 @@ mod tests { Ok(()) } - #[cfg(target_os = "linux")] + #[cfg(unix)] #[test] - fn bounded_command_timeout_kills_descendants() -> Result<(), Box> { + fn recovery_timeout_kills_descendants() -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("descendant-ran"); let mut command = Command::new("sh"); @@ -5076,6 +5264,34 @@ mod tests { Ok(()) } + #[cfg(windows)] + #[test] + fn recovery_timeout_kills_windows_job_descendants() -> Result<(), Box> { + let repo = TempRepo::new()?; + let marker = repo.path().join("windows-descendant-ran"); + let script = format!( + "$child = Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 1; Set-Content -Path ''{}'' -Value ran' -PassThru; Wait-Process -Id $child.Id", + marker.display() + ); + let mut command = Command::new("powershell"); + command.args(["-NoProfile", "-Command", &script]); + + let Err(error) = run_bounded_command( + command, + vec!["windows-job-timeout-test".to_owned()], + Instant::now() + Duration::from_millis(100), + 4096, + false, + || Ok(()), + ) else { + return Err("expected Windows descendant command to time out".into()); + }; + assert!(matches!(error, GitError::TimedOut { .. })); + thread::sleep(Duration::from_millis(1200)); + assert!(!marker.exists()); + Ok(()) + } + #[test] fn recovery_rejects_remaining_rebase_exec_command() -> Result<(), Box> { let (repo, _original_head) = prepare_rebase_conflict()?; @@ -5091,6 +5307,37 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_rejects_persisted_custom_strategy_before_spawn() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let exec_dir = TempRepo::new()?; + let marker = repo.path().join("custom-strategy-ran"); + let strategy = exec_dir.path().join("git-merge-evil"); + fs::write( + &strategy, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + )?; + let mut permissions = fs::metadata(&strategy)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&strategy, permissions)?; + let git = Git::new(repo.path()).with_test_git_exec_path(exec_dir.path()); + fs::write(git.git_path("rebase-merge/strategy")?, "evil\n")?; + + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected a persisted custom strategy to be rejected".into()); + }; + + assert!(error.to_string().contains("non-built-in merge strategy")); + assert!(!marker.exists(), "custom merge strategy was started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + #[cfg(target_os = "linux")] #[test] fn recovery_rejects_tab_delimited_rebase_exec_commands_before_spawn() @@ -5116,11 +5363,12 @@ mod tests { Ok(()) } - #[cfg(target_os = "linux")] + #[cfg(any(unix, windows))] #[test] fn recovery_lock_serializes_bitbygit_execution() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; let held = git.acquire_recovery_lock_until( RepositoryOperation::Merge, @@ -5145,11 +5393,12 @@ mod tests { Ok(()) } - #[cfg(target_os = "linux")] + #[cfg(unix)] #[test] fn recovery_lock_replacement_cannot_enable_concurrent_recovery() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - let git = Git::new(repo.path()); + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; let lock = git.acquire_recovery_lock_until( RepositoryOperation::Merge, @@ -5177,8 +5426,11 @@ mod tests { .contains("another BitByGit recovery") ); - let Err(error) = lock.ensure_identity(RepositoryOperation::Merge, RecoveryAction::Abort) - else { + let Err(error) = lock.ensure_identity( + &lock.repository_root, + RepositoryOperation::Merge, + RecoveryAction::Abort, + ) else { return Err("expected replaced recovery lock to fail identity check".into()); }; @@ -5188,6 +5440,79 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn recovery_lock_survives_git_directory_replacement() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + let original_lock_path = lock.path.clone(); + fs::rename(repo.path().join(".git"), repo.path().join(".git-original"))?; + repo.run(["init", "-b", "main"])?; + let replacement = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + + assert_eq!( + replacement.recovery_lock_path( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &replacement + .canonical_repository_root_until(Instant::now() + Duration::from_secs(2))?, + )?, + original_lock_path + ); + let Err(error) = replacement.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + ) else { + return Err("expected replacement Git directory to use the held app lock".into()); + }; + assert!(error.to_string().contains("another BitByGit recovery")); + Ok(()) + } + + #[test] + fn recovery_lock_key_and_path_are_safe() -> Result<(), Box> { + let repo = initialized_repo()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let root = git.canonical_repository_root_until(Instant::now() + Duration::from_secs(2))?; + let path = + git.recovery_lock_path(RepositoryOperation::Merge, RecoveryAction::Abort, &root)?; + let key = recovery_lock_key_from_path(&path).ok_or("unsafe recovery lock name")?; + let expected_parent = storage.path().join(RECOVERY_LOCK_DIRECTORY); + + assert_eq!(key.len(), 64); + assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(path.parent(), Some(expected_parent.as_path())); + assert!(!repo.path().join(".git/bitbygit-recovery.lock").exists()); + Ok(()) + } + + #[test] + fn recovery_storage_uses_bitbygit_data_root_precedence() { + let resolved = resolve_recovery_data_dir(|name| match name { + "BITBYGIT_DATA_DIR" => Some(PathBuf::from("/custom/data")), + "XDG_DATA_HOME" => Some(PathBuf::from("/xdg/data")), + "APPDATA" => Some(PathBuf::from("/appdata")), + "HOME" => Some(PathBuf::from("/home/test")), + _ => None, + }); + assert_eq!(resolved, Some(PathBuf::from("/custom/data"))); + + let resolved = resolve_recovery_data_dir(|name| match name { + "XDG_DATA_HOME" => Some(PathBuf::from("/xdg/data")), + "HOME" => Some(PathBuf::from("/home/test")), + _ => None, + }); + assert_eq!(resolved, Some(PathBuf::from("/xdg/data/bitbygit"))); + } + #[cfg(target_os = "linux")] #[test] fn exact_recovery_surfaces_standard_git_lock_failure() -> Result<(), Box> { @@ -5217,14 +5542,20 @@ mod tests { Ok(()) } - #[cfg(target_os = "linux")] + #[cfg(any(unix, windows))] #[test] fn recovery_platform_capability_is_available() -> Result<(), Box> { ensure_recovery_execution_supported(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let (repo, _original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + assert_eq!(git.status()?.operation, None); Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(unix, windows)))] #[test] fn recovery_platform_capability_fails_closed_before_planning() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; diff --git a/docs/guardrails.md b/docs/guardrails.md index caa9ca5..41230d8 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -123,12 +123,13 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -On Linux, BitByGit fingerprints bounded tracked, untracked, ignored, control, +BitByGit fingerprints bounded tracked, untracked, ignored, control, configuration, attribute, hook, and ref inputs at preview. It takes an -identity-checked lock in the common Git directory, checks that fingerprint as the -last operation before spawning Git, and holds the lock through execution. A -recovery action in a prompt sequence must be first so this state is captured by -the displayed sequence preview. +identity-checked lock in app-owned data storage keyed by the canonical repository +root, checks that fingerprint as the last operation before spawning Git, and +holds the lock through execution. Replacing a repository's Git directory does +not change this lock identity. A recovery action in a prompt sequence must be +first so this state is captured by the displayed sequence preview. The lock serializes BitByGit recovery processes for the repository. Other Git clients and filesystem writers do not honor an application lock; their normal @@ -136,12 +137,12 @@ concurrency boundary still applies, and BitByGit surfaces resulting Git failures rather than claiming to exclude those writers. Recovery rejects executable hooks, active external filters or merge drivers, -fsmonitor and signing processes, and remaining rebase `exec` commands. This -prevents those configured processes from detaching outside bounded cleanup. -Planning also fails closed when its path, entry, ignored-data, or output limits -are exceeded. On non-Linux platforms, both recovery planning and execution fail -before Git is spawned, with a manual-command diagnostic, until equivalent hard -output and descendant-process bounds are available. +fsmonitor and signing processes, non-built-in persisted rebase strategies, and +remaining rebase `exec` commands. This prevents those configured processes from +detaching outside bounded cleanup. Planning also fails closed when its path, +entry, ignored-data, or output limits are exceeded. Linux uses sealed capture +files, macOS uses bounded temporary captures, Unix places Git in a process group, +and Windows uses bounded temporary captures plus a kill-on-close Job Object. ## Confirmation Copy From 7460515876fb0571a6a9f4023635fae0bf1eab6b Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:36:45 -0400 Subject: [PATCH 39/53] fix: correct portable recovery checks --- .github/workflows/ci.yml | 2 +- crates/bitbygit-git/src/lib.rs | 42 +++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9814cc7..ff7da64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Test recovery support - run: cargo test --locked -p bitbygit-git recovery_ + run: cargo test --locked -p bitbygit-git recovery_platform_ - name: Build workspace run: cargo build --locked --workspace diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 4a2ad44..cdf673f 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -2225,6 +2225,10 @@ where } thread::sleep(Duration::from_millis(5).min(deadline.saturating_duration_since(now))); }; + if let Err(error) = ensure_capture_limits(&mut stdout, &mut stderr, capture_limit, &args) { + kill_process_tree(&mut child); + return Err(error); + } // Hooks may leave background children behind. End the command's process group // before reading finite file snapshots; detached children cannot hold these // captures open as they could inherited pipes. @@ -2331,6 +2335,7 @@ fn bounded_capture_file(_capacity: usize, args: &[String]) -> Result Result { + let length = file + .metadata() + .map_err(|source| recovery_output_io(args, source))? + .len(); + if length > capacity as u64 { + return Err(GitError::Blocked { + message: format!( + "git {} output exceeded the bounded capture limit", + args.join(" ") + ), + }); + } + Ok(length) +} + fn recovery_output_io(args: &[String], source: io::Error) -> GitError { GitError::Io { args: args.to_vec(), @@ -2543,7 +2569,7 @@ fn recovery_lock_error( action: RecoveryAction, source: io::Error, ) -> GitError { - if source.kind() == io::ErrorKind::WouldBlock { + if source.raw_os_error() == fs2::lock_contended_error().raw_os_error() { GitError::Blocked { message: format!( "{} {} is blocked because another BitByGit recovery is active for this repository", @@ -4700,7 +4726,7 @@ mod tests { #[cfg(any(target_os = "macos", windows))] #[test] - fn recovery_command_output_is_bounded() -> Result<(), Box> { + fn recovery_platform_command_output_is_bounded() -> Result<(), Box> { #[cfg(unix)] let mut command = { let mut command = Command::new("sh"); @@ -4710,7 +4736,7 @@ mod tests { #[cfg(windows)] let mut command = { let mut command = Command::new("cmd"); - command.args(["/C", "for /L %i in (1,1,10000) do @echo 0123456789"]); + command.args(["/C", "for /L %i in (1,1,1000000) do @echo 0123456789"]); command }; configure_process_group(&mut command); @@ -5237,7 +5263,7 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_timeout_kills_descendants() -> Result<(), Box> { + fn recovery_platform_timeout_kills_descendants() -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("descendant-ran"); let mut command = Command::new("sh"); @@ -5266,7 +5292,7 @@ mod tests { #[cfg(windows)] #[test] - fn recovery_timeout_kills_windows_job_descendants() -> Result<(), Box> { + fn recovery_platform_timeout_kills_windows_job_descendants() -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("windows-descendant-ran"); let script = format!( @@ -5365,7 +5391,7 @@ mod tests { #[cfg(any(unix, windows))] #[test] - fn recovery_lock_serializes_bitbygit_execution() -> Result<(), Box> { + fn recovery_platform_lock_serializes_bitbygit_execution() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; let storage = TempRepo::new()?; let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); @@ -5477,7 +5503,7 @@ mod tests { } #[test] - fn recovery_lock_key_and_path_are_safe() -> Result<(), Box> { + fn recovery_platform_lock_key_and_path_are_safe() -> Result<(), Box> { let repo = initialized_repo()?; let storage = TempRepo::new()?; let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); @@ -5485,7 +5511,7 @@ mod tests { let path = git.recovery_lock_path(RepositoryOperation::Merge, RecoveryAction::Abort, &root)?; let key = recovery_lock_key_from_path(&path).ok_or("unsafe recovery lock name")?; - let expected_parent = storage.path().join(RECOVERY_LOCK_DIRECTORY); + let expected_parent = fs::canonicalize(storage.path().join(RECOVERY_LOCK_DIRECTORY))?; assert_eq!(key.len(), 64); assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit())); From a513b89ffa079b9f235a11989a448ad72b1c8b44 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:38:34 -0400 Subject: [PATCH 40/53] test: stabilize Windows output bound --- crates/bitbygit-git/src/lib.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index cdf673f..eda2f71 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -4735,8 +4735,12 @@ mod tests { }; #[cfg(windows)] let mut command = { - let mut command = Command::new("cmd"); - command.args(["/C", "for /L %i in (1,1,1000000) do @echo 0123456789"]); + let mut command = Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + "[Console]::Out.Write('x' * 5000000)", + ]); command }; configure_process_group(&mut command); @@ -4752,7 +4756,10 @@ mod tests { return Err("expected oversized recovery output to be stopped".into()); }; - assert!(error.to_string().contains("bounded capture limit")); + assert!( + error.to_string().contains("bounded capture limit"), + "unexpected error: {error}" + ); Ok(()) } From 48551b10fa212466de5b5007871a1b6093649b84 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 19:00:31 -0400 Subject: [PATCH 41/53] fix: close remaining recovery races --- Cargo.lock | 87 ++------ crates/bitbygit-git/Cargo.toml | 2 +- crates/bitbygit-git/src/lib.rs | 397 ++++++++++++++++++--------------- 3 files changed, 241 insertions(+), 245 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8a9ffe..21ff680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,9 @@ version = "0.1.0" dependencies = [ "command-group", "fs2", - "rustix 0.38.44", + "rustix", + "same-file", "sha2", - "tempfile", ] [[package]] @@ -143,7 +143,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -235,12 +235,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "fnv" version = "1.0.7" @@ -273,17 +267,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -372,12 +355,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "lock_api" version = "0.4.14" @@ -431,12 +408,6 @@ dependencies = [ "libc", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "parking_lot" version = "0.12.5" @@ -484,12 +455,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "ratatui" version = "0.29.0" @@ -529,23 +494,10 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -558,6 +510,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -709,19 +670,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - [[package]] name = "toml" version = "0.8.23" @@ -832,6 +780,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index bcf4216..1664feb 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -12,10 +12,10 @@ workspace = true [dependencies] fs2 = "0.4" sha2 = "0.10" -tempfile = "3.20" [target.'cfg(unix)'.dependencies] rustix = { version = "0.38", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] command-group = "5.0" +same-file = "1.0" diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index eda2f71..c8ac843 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -4,11 +4,12 @@ use std::error::Error; use std::ffi::OsString; use std::fmt::{self, Display, Formatter}; use std::fs; -use std::io::{self, Read, Seek, SeekFrom}; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; +use std::process::{ChildStderr, ChildStdout, Command, ExitStatus, Stdio}; use std::string::FromUtf8Error; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::{Duration, Instant}; @@ -27,8 +28,6 @@ use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::process::CommandExt; -#[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] -use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, ftruncate, memfd_create}; #[cfg(unix)] use rustix::process::{Pid, Signal, kill_process_group}; #[cfg(unix)] @@ -57,8 +56,6 @@ const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; -const RECOVERY_BUILT_IN_MERGE_STRATEGIES: &[&str] = - &["octopus", "ort", "ours", "recursive", "resolve", "subtree"]; const RECOVERY_HOOKS: &[&str] = &[ "applypatch-msg", "commit-msg", @@ -450,12 +447,16 @@ impl Git { self.ensure_recovery_process_configuration_safe_until(deadline)?; let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; self.run_recovery_args_until_with(operation, action, deadline, || { - before_spawn_check()?; let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; self.ensure_recovery_state(expected_state, operation, action, deadline)?; + before_spawn_check()?; let repository_root = self.canonical_repository_root_until(deadline)?; - recovery_lock.ensure_identity(&repository_root, operation, action) + recovery_lock.ensure_identity(&repository_root, operation, action)?; + // A second complete pass catches a mutation that occurred after an + // earlier input was consumed by the first pass. Keep this final + // pass as the last operation before spawning the recovery command. + self.ensure_recovery_state(expected_state, operation, action, deadline) }) } @@ -1429,18 +1430,22 @@ impl Git { }); } } - for relative in ["rebase-merge/strategy", "rebase-apply/strategy"] { + for relative in [ + "rebase-merge/strategy", + "rebase-apply/strategy", + "rebase-merge/strategy_opts", + "rebase-apply/strategy_opts", + ] { let path = self.git_path_until(relative, deadline)?; - let Some(strategy) = + let Some(value) = read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? else { continue; }; - let strategy = strategy.trim(); - if !strategy.is_empty() && !RECOVERY_BUILT_IN_MERGE_STRATEGIES.contains(&strategy) { + if !value.trim().is_empty() { return Err(GitError::Blocked { message: format!( - "recovery is blocked because the active rebase requests non-built-in merge strategy {strategy:?}, which may start an uncontained executable; abort and restart with a built-in strategy, or run Git manually" + "recovery is blocked because the active rebase persists an explicit merge strategy or strategy option in {relative}, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" ), }); } @@ -2180,22 +2185,10 @@ where } else { RECOVERY_EXECUTION_CAPTURE_LIMIT.max(output_limit.saturating_add(1)) }; - let mut stdout = bounded_capture_file(capture_limit, &args)?; - let mut stderr = bounded_capture_file(capture_limit, &args)?; command .stdin(Stdio::null()) - .stdout(Stdio::from(stdout.try_clone().map_err(|source| { - GitError::Io { - args: args.clone(), - source, - } - })?)) - .stderr(Stdio::from(stderr.try_clone().map_err(|source| { - GitError::Io { - args: args.clone(), - source, - } - })?)); + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); if Instant::now() >= deadline { return Err(GitError::TimedOut { args }); } @@ -2204,12 +2197,41 @@ where args: args.clone(), source, })?; + let (stdout, stderr) = take_contained_output(&mut child).map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + let capture_exceeded = Arc::new(AtomicBool::new(false)); + let stdout_exceeded = Arc::clone(&capture_exceeded); + let stderr_exceeded = Arc::clone(&capture_exceeded); + let stdout_reader = thread::spawn(move || { + drain_bounded_output( + stdout, + output_limit, + capture_limit, + digest_all_output, + stdout_exceeded, + ) + }); + let stderr_reader = thread::spawn(move || { + drain_bounded_output( + stderr, + output_limit, + capture_limit, + digest_all_output, + stderr_exceeded, + ) + }); + let mut output_limit_hit = false; let status = loop { - if let Err(error) = ensure_capture_limits(&mut stdout, &mut stderr, capture_limit, &args) { + if capture_exceeded.load(Ordering::Acquire) { + output_limit_hit = true; kill_process_tree(&mut child); - let _result = child.wait(); - return Err(error); + break child.wait().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; } if let Some(status) = child.try_wait().map_err(|source| GitError::Io { args: args.clone(), @@ -2221,159 +2243,79 @@ where if now >= deadline { kill_process_tree(&mut child); let _result = child.wait(); + kill_process_tree(&mut child); + join_capture_reader(stdout_reader, &args)?; + join_capture_reader(stderr_reader, &args)?; return Err(GitError::TimedOut { args }); } thread::sleep(Duration::from_millis(5).min(deadline.saturating_duration_since(now))); }; - if let Err(error) = ensure_capture_limits(&mut stdout, &mut stderr, capture_limit, &args) { - kill_process_tree(&mut child); - return Err(error); - } - // Hooks may leave background children behind. End the command's process group - // before reading finite file snapshots; detached children cannot hold these - // captures open as they could inherited pipes. + // End the contained process tree before joining the drains so descendants + // cannot keep inherited pipe writers open. kill_process_tree(&mut child); - let stdout_len = captured_file_position(&mut stdout, capture_limit, &args)?; - let stderr_len = captured_file_position(&mut stderr, capture_limit, &args)?; + let stdout = join_capture_reader(stdout_reader, &args)?; + let stderr = join_capture_reader(stderr_reader, &args)?; + if output_limit_hit || capture_exceeded.load(Ordering::Acquire) { + return Err(GitError::Blocked { + message: format!( + "git {} output exceeded the bounded capture limit", + args.join(" ") + ), + }); + } Ok(BoundedCommandOutput { status, - stdout: read_captured_file( - &mut stdout, - stdout_len, - output_limit, - digest_all_output, - deadline, - &args, - )?, - stderr: read_captured_file( - &mut stderr, - stderr_len, - output_limit, - digest_all_output, - deadline, - &args, - )?, + stdout, + stderr, }) } -fn read_captured_file( - file: &mut fs::File, - snapshot_len: u64, +fn drain_bounded_output( + mut reader: impl Read, output_limit: usize, + capture_limit: usize, digest_all_output: bool, - deadline: Instant, - args: &[String], -) -> Result { - file.seek(SeekFrom::Start(0)) - .map_err(|source| recovery_output_io(args, source))?; - let read_limit = if digest_all_output { - snapshot_len - } else { - snapshot_len.min(output_limit.saturating_add(1) as u64) - }; + exceeded: Arc, +) -> io::Result { let mut hasher = Sha256::new(); - let mut bytes = Vec::with_capacity(output_limit.min(8192)); - let mut remaining = read_limit; + let mut bytes = Vec::with_capacity(output_limit.min(capture_limit).min(8192)); + let mut total = 0usize; let mut buffer = [0; 8192]; - while remaining > 0 { - if Instant::now() >= deadline { - return Err(GitError::TimedOut { - args: args.to_vec(), - }); - } - let requested = buffer.len().min(remaining as usize); - let count = file - .read(&mut buffer[..requested]) - .map_err(|source| recovery_output_io(args, source))?; + loop { + let count = reader.read(&mut buffer)?; if count == 0 { break; } - remaining -= count as u64; - hasher.update(&buffer[..count]); - let remaining = output_limit.saturating_sub(bytes.len()); - let retained = remaining.min(count); + let within_limit = capture_limit.saturating_sub(total).min(count); + if digest_all_output { + hasher.update(&buffer[..within_limit]); + } + let retained = output_limit.saturating_sub(bytes.len()).min(within_limit); bytes.extend_from_slice(&buffer[..retained]); + total = total.saturating_add(count); + if total > capture_limit { + exceeded.store(true, Ordering::Release); + } + } + if !digest_all_output { + hasher.update(&bytes); } Ok(CapturedStream { bytes, digest: hasher.finalize().into(), - truncated: snapshot_len > output_limit as u64, + truncated: total > output_limit, }) } -fn ensure_capture_limits( - stdout: &mut fs::File, - stderr: &mut fs::File, - capacity: usize, +fn join_capture_reader( + reader: thread::JoinHandle>, args: &[String], -) -> Result<(), GitError> { - let _stdout = captured_file_position(stdout, capacity, args)?; - let _stderr = captured_file_position(stderr, capacity, args)?; - Ok(()) -} - -#[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] -fn bounded_capture_file(capacity: usize, args: &[String]) -> Result { - let descriptor = memfd_create( - "bitbygit-recovery-output", - MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING, - ) - .map_err(|source| recovery_output_io(args, source.into()))?; - ftruncate(&descriptor, capacity as u64) - .map_err(|source| recovery_output_io(args, source.into()))?; - fcntl_add_seals( - &descriptor, - SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL, - ) - .map_err(|source| recovery_output_io(args, source.into()))?; - Ok(fs::File::from(descriptor)) -} - -#[cfg(not(any(target_os = "android", target_os = "freebsd", target_os = "linux")))] -fn bounded_capture_file(_capacity: usize, args: &[String]) -> Result { - tempfile::tempfile().map_err(|source| recovery_output_io(args, source)) -} - -#[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] -fn captured_file_position( - file: &mut fs::File, - capacity: usize, - args: &[String], -) -> Result { - let position = file - .stream_position() - .map_err(|source| recovery_output_io(args, source))?; - if position > capacity as u64 { - return Err(GitError::Blocked { - message: format!( - "git {} output exceeded the bounded capture limit", - args.join(" ") - ), - }); - } - Ok(position) -} - -#[cfg(not(any(target_os = "android", target_os = "freebsd", target_os = "linux")))] -fn captured_file_position( - file: &mut fs::File, - capacity: usize, - args: &[String], -) -> Result { - let length = file - .metadata() - .map_err(|source| recovery_output_io(args, source))? - .len(); - if length > capacity as u64 { - return Err(GitError::Blocked { - message: format!( - "git {} output exceeded the bounded capture limit", - args.join(" ") - ), - }); - } - Ok(length) +) -> Result { + reader + .join() + .map_err(|_| recovery_output_io(args, io::Error::other("output drainer panicked")))? + .map_err(|source| recovery_output_io(args, source)) } fn recovery_output_io(args: &[String], source: io::Error) -> GitError { @@ -2396,16 +2338,58 @@ fn spawn_contained(mut command: Command) -> io::Result { command.spawn() } +#[cfg(unix)] +fn take_contained_output(child: &mut Child) -> io::Result<(ChildStdout, ChildStderr)> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + #[cfg(windows)] fn spawn_contained(mut command: Command) -> io::Result { command.group().kill_on_drop(true).spawn() } +#[cfg(windows)] +fn take_contained_output(child: &mut GroupChild) -> io::Result<(ChildStdout, ChildStderr)> { + let child = child.inner(); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + #[cfg(not(any(unix, windows)))] fn spawn_contained(mut command: Command) -> io::Result { command.spawn() } +#[cfg(not(any(unix, windows)))] +fn take_contained_output( + child: &mut std::process::Child, +) -> io::Result<(ChildStdout, ChildStderr)> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("contained command has no stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("contained command has no stderr pipe"))?; + Ok((stdout, stderr)) +} + #[cfg(unix)] fn kill_process_tree(child: &mut Child) { if let Some(pid) = Pid::from_raw(child.id() as i32) { @@ -2491,15 +2475,24 @@ impl RecoveryLock { .map_err(|source| recovery_lock_io(operation, action, source))?; let current_guard = fs::symlink_metadata(&self.guard_path) .map_err(|source| recovery_lock_io(operation, action, source))?; + let same_lock = same_recovery_lock_identity(&self.file, &self.path, &opened, ¤t) + .map_err(|source| recovery_lock_io(operation, action, source))?; + let same_guard = same_recovery_lock_identity( + &self.guard, + &self.guard_path, + &opened_guard, + ¤t_guard, + ) + .map_err(|source| recovery_lock_io(operation, action, source))?; if current_repository_root != self.repository_root || !opened.is_file() || !current.is_file() || current.file_type().is_symlink() - || !same_recovery_file(&opened, ¤t) + || !same_lock || !opened_guard.is_file() || !current_guard.is_file() || current_guard.file_type().is_symlink() - || !same_recovery_file(&opened_guard, ¤t_guard) + || !same_guard || recovery_repository_key(current_repository_root) != recovery_lock_key_from_path(&self.path).unwrap_or_default() { @@ -2525,6 +2518,28 @@ impl RecoveryLock { } } +#[cfg(windows)] +fn same_recovery_lock_identity( + opened: &fs::File, + path: &Path, + _opened_metadata: &fs::Metadata, + _path_metadata: &fs::Metadata, +) -> io::Result { + let opened = same_file::Handle::from_file(opened.try_clone()?)?; + let current = same_file::Handle::from_path(path)?; + Ok(opened == current) +} + +#[cfg(not(windows))] +fn same_recovery_lock_identity( + _opened: &fs::File, + _path: &Path, + opened_metadata: &fs::Metadata, + path_metadata: &fs::Metadata, +) -> io::Result { + Ok(same_recovery_file(opened_metadata, path_metadata)) +} + #[cfg(unix)] fn open_recovery_lock_file( path: &Path, @@ -4711,16 +4726,20 @@ mod tests { Ok(()) } - #[cfg(any(target_os = "android", target_os = "freebsd", target_os = "linux"))] #[test] - fn recovery_output_file_has_a_hard_growth_limit() -> Result<(), Box> { - use std::io::Write; - - let args = ["merge".to_owned(), "--continue".to_owned()]; - let mut capture = bounded_capture_file(4096, &args)?; - capture.write_all(&vec![b'x'; 4096])?; + fn recovery_output_drainer_retains_only_hard_bounded_memory() -> Result<(), Box> { + let exceeded = Arc::new(AtomicBool::new(false)); + let capture = drain_bounded_output( + io::Cursor::new(vec![b'x'; 8192]), + 1024, + 4096, + true, + Arc::clone(&exceeded), + )?; - assert!(capture.write_all(b"x").is_err()); + assert_eq!(capture.bytes.len(), 1024); + assert!(capture.truncated); + assert!(exceeded.load(Ordering::Acquire)); Ok(()) } @@ -4947,8 +4966,8 @@ mod tests { } #[test] - fn exact_recovery_detects_synchronized_change_at_spawn_boundary() -> Result<(), Box> - { + fn exact_recovery_rejects_index_change_between_complete_final_fingerprints() + -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; repo.write("conflict.txt", "resolved\n")?; repo.run(["add", "conflict.txt"])?; @@ -4983,7 +5002,7 @@ mod tests { Ok(()) }, ) else { - return Err("expected final spawn-boundary check to block recovery".into()); + return Err("expected final complete fingerprint to block recovery".into()); }; assert!(error.to_string().contains("state changed after preview")); @@ -5342,15 +5361,15 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_rejects_persisted_custom_strategy_before_spawn() -> Result<(), Box> { + fn recovery_rejects_persisted_explicit_strategy_before_spawn() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_rebase_conflict()?; repo.write("conflict.txt", "resolved\n")?; repo.run(["add", "conflict.txt"])?; let exec_dir = TempRepo::new()?; - let marker = repo.path().join("custom-strategy-ran"); - let strategy = exec_dir.path().join("git-merge-evil"); + let marker = repo.path().join("explicit-strategy-ran"); + let strategy = exec_dir.path().join("git-merge-resolve"); fs::write( &strategy, format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), @@ -5359,14 +5378,34 @@ mod tests { permissions.set_mode(0o755); fs::set_permissions(&strategy, permissions)?; let git = Git::new(repo.path()).with_test_git_exec_path(exec_dir.path()); - fs::write(git.git_path("rebase-merge/strategy")?, "evil\n")?; + fs::write(git.git_path("rebase-merge/strategy")?, "resolve\n")?; + + let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { + return Err("expected a persisted explicit strategy to be rejected".into()); + }; + + assert!(error.to_string().contains("explicit merge strategy")); + assert!(!marker.exists(), "explicit merge strategy was started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + + #[test] + fn recovery_rejects_persisted_strategy_options_before_spawn() -> Result<(), Box> { + let (repo, _original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + fs::write( + git.git_path("rebase-merge/strategy_opts")?, + "--evil-option\n", + )?; let Err(error) = git.recover(RepositoryOperation::Rebase, RecoveryAction::Continue) else { - return Err("expected a persisted custom strategy to be rejected".into()); + return Err("expected persisted strategy options to be rejected".into()); }; - assert!(error.to_string().contains("non-built-in merge strategy")); - assert!(!marker.exists(), "custom merge strategy was started"); + assert!(error.to_string().contains("strategy option")); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) } @@ -5426,7 +5465,7 @@ mod tests { Ok(()) } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn recovery_lock_replacement_cannot_enable_concurrent_recovery() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -5446,7 +5485,7 @@ mod tests { )?; let replaced = lock.path.with_extension("replaced"); fs::rename(&lock.path, &replaced)?; - fs::write(&lock.path, "replacement\n")?; + fs::File::create(&lock.path)?; let Err(concurrent_error) = git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected) From ad8936dc2cef1d1ac581084c901a0f6a06f0c254 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:06:01 -0400 Subject: [PATCH 42/53] fix: fence final recovery inputs --- crates/bitbygit-git/src/lib.rs | 475 ++++++++++++++++++++++++++++++--- 1 file changed, 437 insertions(+), 38 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index c8ac843..881a436 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -449,17 +449,25 @@ impl Git { self.run_recovery_args_until_with(operation, action, deadline, || { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; - self.ensure_recovery_state(expected_state, operation, action, deadline)?; - before_spawn_check()?; + let current_state = match self.recovery_state_fenced_until(deadline, before_spawn_check) + { + Ok(state) => state, + Err(GitError::Blocked { message }) + if message.contains("changed while it was fingerprinted") => + { + return Err(recovery_state_changed(operation, action)); + } + Err(error) => return Err(error), + }; + if current_state != *expected_state { + return Err(recovery_state_changed(operation, action)); + } let repository_root = self.canonical_repository_root_until(deadline)?; - recovery_lock.ensure_identity(&repository_root, operation, action)?; - // A second complete pass catches a mutation that occurred after an - // earlier input was consumed by the first pass. Keep this final - // pass as the last operation before spawning the recovery command. - self.ensure_recovery_state(expected_state, operation, action, deadline) + recovery_lock.ensure_identity(&repository_root, operation, action) }) } + #[cfg(test)] fn ensure_recovery_state( &self, expected_state: &RecoveryState, @@ -470,13 +478,7 @@ impl Git { if self.recovery_state_until(deadline)? == *expected_state { return Ok(()); } - Err(GitError::Blocked { - message: format!( - "{} {} is blocked because repository state changed after preview", - operation.label(), - action.label() - ), - }) + Err(recovery_state_changed(operation, action)) } fn validate_recovery_until( @@ -1280,6 +1282,31 @@ impl Git { } fn recovery_state_until(&self, deadline: Instant) -> Result { + self.recovery_state_until_with(deadline, None, &mut None:: Result<(), GitError>>) + } + + fn recovery_state_fenced_until( + &self, + deadline: Instant, + after_index: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let mut fence = RecoveryInputFence::default(); + let mut after_index = Some(after_index); + self.recovery_state_until_with(deadline, Some(&mut fence), &mut after_index) + } + + fn recovery_state_until_with( + &self, + deadline: Instant, + mut fence: Option<&mut RecoveryInputFence>, + after_index: &mut Option, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { let mut hasher = Sha256::new(); for args in [ vec![ @@ -1324,29 +1351,49 @@ impl Git { return Err(output.git_error(args)); } hash_field(&mut hasher, &output.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + }); + } } let mut entries = 0; for relative in RECOVERY_CONTROL_PATHS { let path = self.git_path_until(relative, deadline)?; - hash_recovery_path( + hash_recovery_path_fenced( &mut hasher, relative.as_bytes(), &path, deadline, &mut entries, + fence.as_deref_mut(), )?; + if *relative == "index" + && let Some(after_index) = after_index.take() + { + after_index()?; + } } let hooks = self.recovery_hooks_path_until(deadline)?; - hash_recovery_path(&mut hasher, b"hooks", &hooks, deadline, &mut entries)?; + hash_recovery_path_fenced( + &mut hasher, + b"hooks", + &hooks, + deadline, + &mut entries, + fence.as_deref_mut(), + )?; for variable in ["GIT_ATTR_SYSTEM", "GIT_ATTR_GLOBAL"] { if let Some(path) = self.git_var_path_until(variable, deadline)? { - hash_recovery_path( + hash_recovery_path_fenced( &mut hasher, variable.as_bytes(), &path, deadline, &mut entries, + fence.as_deref_mut(), )?; } } @@ -1362,6 +1409,7 @@ impl Git { follow_symlinks: false, byte_budget: &mut untracked_bytes_remaining, byte_budget_error: "recovery planning is blocked because untracked worktree data exceeds the 64 MiB fingerprint limit", + fence: fence.as_deref_mut(), }; hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { Ok(()) @@ -1378,6 +1426,7 @@ impl Git { follow_symlinks: false, byte_budget: &mut ignored_bytes_remaining, byte_budget_error: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit", + fence: fence.as_deref_mut(), }; hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { Ok(()) @@ -1386,15 +1435,20 @@ impl Git { for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); - hash_recovery_path( + hash_recovery_path_fenced( &mut hasher, &label, &root.join(relative), deadline, &mut entries, + fence.as_deref_mut(), )?; } + if let Some(fence) = fence { + fence.validate(self, deadline)?; + } + Ok(RecoveryState { fingerprint: hasher.finalize().into(), }) @@ -1776,6 +1830,14 @@ impl Git { ) -> Result { let repository_root = self.canonical_repository_root_until(deadline)?; let path = self.recovery_lock_path(operation, action, &repository_root)?; + let directory_path = path + .parent() + .ok_or_else(|| GitError::Blocked { + message: "recovery is blocked because the BitByGit recovery lock directory is unavailable" + .to_owned(), + })? + .to_owned(); + let directory = open_recovery_lock_directory(&directory_path, operation, action)?; let guard_path = path.with_extension("guard"); let guard = open_recovery_lock_file(&guard_path, operation, action)?; guard @@ -1789,6 +1851,8 @@ impl Git { file, guard_path, guard, + directory_path, + directory, repository_root, }; lock.ensure_identity(&lock.repository_root, operation, action)?; @@ -2453,6 +2517,8 @@ struct RecoveryLock { file: fs::File, guard_path: PathBuf, guard: fs::File, + directory_path: PathBuf, + directory: RecoveryFile, repository_root: PathBuf, } @@ -2475,6 +2541,8 @@ impl RecoveryLock { .map_err(|source| recovery_lock_io(operation, action, source))?; let current_guard = fs::symlink_metadata(&self.guard_path) .map_err(|source| recovery_lock_io(operation, action, source))?; + let current_directory = + open_recovery_lock_directory(&self.directory_path, operation, action)?; let same_lock = same_recovery_lock_identity(&self.file, &self.path, &opened, ¤t) .map_err(|source| recovery_lock_io(operation, action, source))?; let same_guard = same_recovery_lock_identity( @@ -2484,7 +2552,14 @@ impl RecoveryLock { ¤t_guard, ) .map_err(|source| recovery_lock_io(operation, action, source))?; + let same_directory = self + .directory + .same_path_identity(¤t_directory) + .map_err(|source| recovery_lock_io(operation, action, source))?; if current_repository_root != self.repository_root + || !self.directory.metadata.is_dir() + || !current_directory.metadata.is_dir() + || !same_directory || !opened.is_file() || !current.is_file() || current.file_type().is_symlink() @@ -2518,6 +2593,26 @@ impl RecoveryLock { } } +fn open_recovery_lock_directory( + path: &Path, + operation: RepositoryOperation, + action: RecoveryAction, +) -> Result { + match open_recovery_file(path) { + Ok(opened) if opened.metadata.is_dir() => Ok(opened), + Ok(_) | Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + Err(GitError::Blocked { + message: format!( + "{} {} is blocked because the BitByGit recovery lock identity changed", + operation.label(), + action.label() + ), + }) + } + Err(RecoveryOpenError::Io(source)) => Err(recovery_lock_io(operation, action, source)), + } +} + #[cfg(windows)] fn same_recovery_lock_identity( opened: &fs::File, @@ -2656,7 +2751,8 @@ fn read_bounded_optional_file( ), }); } - let Some(mut file) = opened.file else { + let mut opened = opened; + let Some(file) = opened.file.as_mut() else { return Err(recovery_state_io( path, io::Error::other("regular recovery input has no open descriptor"), @@ -2697,21 +2793,24 @@ fn read_bounded_optional_file( let final_metadata = file .metadata() .map_err(|source| recovery_state_io(path, source))?; - let path_metadata = match open_recovery_file(path) { - Ok(reopened) => reopened.metadata, + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { return Err(recovery_path_changed(path)); } }; if !same_recovery_file(&opened.metadata, &final_metadata) - || !same_recovery_file(&opened.metadata, &path_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? { return Err(recovery_path_changed(path)); } Ok(Some(String::from_utf8_lossy(&contents).into_owned())) } +#[cfg(all(test, unix))] fn hash_recovery_path( hasher: &mut Sha256, label: &[u8], @@ -2719,9 +2818,29 @@ fn hash_recovery_path( deadline: Instant, entries: &mut usize, ) -> Result<(), GitError> { - hash_recovery_path_with(hasher, label, path, deadline, entries, &mut |_, _| Ok(())) + hash_recovery_path_fenced(hasher, label, path, deadline, entries, None) } +fn hash_recovery_path_fenced( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + fence: Option<&mut RecoveryInputFence>, +) -> Result<(), GitError> { + hash_recovery_path_with_fence( + hasher, + label, + path, + deadline, + entries, + fence, + &mut |_, _| Ok(()), + ) +} + +#[cfg(test)] fn hash_recovery_path_with( hasher: &mut Sha256, label: &[u8], @@ -2730,6 +2849,21 @@ fn hash_recovery_path_with( entries: &mut usize, after_contents: &mut F, ) -> Result<(), GitError> +where + F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, +{ + hash_recovery_path_with_fence(hasher, label, path, deadline, entries, None, after_contents) +} + +fn hash_recovery_path_with_fence( + hasher: &mut Sha256, + label: &[u8], + path: &Path, + deadline: Instant, + entries: &mut usize, + fence: Option<&mut RecoveryInputFence>, + after_contents: &mut F, +) -> Result<(), GitError> where F: FnMut(&Path, RecoveryPathKind) -> Result<(), GitError>, { @@ -2740,16 +2874,112 @@ where follow_symlinks: true, byte_budget: &mut byte_budget, byte_budget_error: "recovery planning is blocked because recovery input data exceeds its fingerprint limit", + fence, }; hash_recovery_path_inner(hasher, label, path, 0, &mut context, after_contents) } +#[derive(Default)] +struct RecoveryInputFence { + inputs: Vec, + probes: Vec, +} + +struct RecoveryProbe { + args: Vec, + digest: [u8; 32], +} + +enum RecoveryInput { + Missing(PathBuf), + Symlink { + path: PathBuf, + target: PathBuf, + metadata: fs::Metadata, + }, + Opened { + path: PathBuf, + opened: RecoveryFile, + }, +} + +impl RecoveryInputFence { + fn validate(&self, git: &Git, deadline: Instant) -> Result<(), GitError> { + for probe in &self.probes { + let output = git.run_bounded_git(probe.args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(probe.args.clone())); + } + if output.stdout.digest != probe.digest { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git {} output changed while it was fingerprinted", + probe.args.join(" ") + ), + }); + } + } + for input in &self.inputs { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + match input { + RecoveryInput::Missing(path) => match fs::symlink_metadata(path) { + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Ok(_) => return Err(recovery_path_changed(path)), + Err(source) => return Err(recovery_state_io(path, source)), + }, + RecoveryInput::Symlink { + path, + target, + metadata, + } => { + let current_target = + fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let current_metadata = recovery_symlink_metadata(path)?; + if current_target != *target || !same_recovery_file(metadata, ¤t_metadata) + { + return Err(recovery_path_changed(path)); + } + } + RecoveryInput::Opened { path, opened } => { + let final_metadata = opened + .file + .as_ref() + .map_or_else(|| fs::symlink_metadata(path), fs::File::metadata) + .map_err(|source| recovery_state_io(path, source))?; + let current = match open_recovery_file(path) { + Ok(current) => current, + Err(RecoveryOpenError::Io(source)) => { + return Err(recovery_state_io(path, source)); + } + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { + return Err(recovery_path_changed(path)); + } + }; + if !same_recovery_file(&opened.metadata, &final_metadata) + || !opened + .same_path_identity(¤t) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(recovery_path_changed(path)); + } + } + } + } + Ok(()) + } +} + struct RecoveryHashContext<'a> { deadline: Instant, entries: &'a mut usize, follow_symlinks: bool, byte_budget: &'a mut Option, byte_budget_error: &'static str, + fence: Option<&'a mut RecoveryInputFence>, } fn hash_recovery_path_inner( @@ -2769,6 +2999,9 @@ where Ok(opened) => opened, Err(RecoveryOpenError::Missing) => { hash_field(hasher, b"missing"); + if let Some(fence) = context.fence.as_deref_mut() { + fence.inputs.push(RecoveryInput::Missing(path.to_owned())); + } return Ok(()); } Err(RecoveryOpenError::Symlink) => { @@ -2814,12 +3047,19 @@ where if final_target != target || !same_recovery_file(&metadata, &final_metadata) { return Err(recovery_path_changed(path)); } + if let Some(fence) = context.fence.as_deref_mut() { + fence.inputs.push(RecoveryInput::Symlink { + path: path.to_owned(), + target, + metadata, + }); + } return Ok(()); } Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), }; - let mut file = opened.file; - let metadata = opened.metadata; + let mut opened = opened; + let metadata = opened.metadata.clone(); *context.entries += 1; hash_field(hasher, &metadata.len().to_le_bytes()); #[cfg(unix)] @@ -2829,7 +3069,7 @@ where if metadata.is_file() { hash_field(hasher, b"file"); - let Some(file) = file.as_mut() else { + let Some(file) = opened.file.as_mut() else { return Err(recovery_state_io( path, io::Error::other("regular recovery input has no open descriptor"), @@ -2861,8 +3101,9 @@ where let final_metadata = file .metadata() .map_err(|source| recovery_state_io(path, source))?; - let path_metadata = match open_recovery_file(path) { - Ok(reopened) => reopened.metadata, + after_contents(path, RecoveryPathKind::File)?; + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { return Err(GitError::Blocked { @@ -2874,7 +3115,9 @@ where } }; if !same_recovery_file(&metadata, &final_metadata) - || !same_recovery_file(&metadata, &path_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? { return Err(GitError::Blocked { message: format!( @@ -2885,8 +3128,12 @@ where } } else if metadata.is_dir() { hash_field(hasher, b"directory"); - let mut children = - recovery_directory_children(file.as_ref(), path, context.deadline, context.entries)?; + let mut children = recovery_directory_children( + opened.file.as_ref(), + path, + context.deadline, + context.entries, + )?; children.sort(); for child in children { ensure_recovery_fingerprint_capacity(context.deadline, context.entries)?; @@ -2903,14 +3150,14 @@ where )?; } after_contents(path, RecoveryPathKind::Directory)?; - let final_metadata = match file.as_ref() { + let final_metadata = match opened.file.as_ref() { Some(file) => file .metadata() .map_err(|source| recovery_state_io(path, source))?, None => fs::symlink_metadata(path).map_err(|source| recovery_state_io(path, source))?, }; - let path_metadata = match open_recovery_file(path) { - Ok(reopened) => reopened.metadata, + let reopened = match open_recovery_file(path) { + Ok(reopened) => reopened, Err(RecoveryOpenError::Io(source)) => return Err(recovery_state_io(path, source)), Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { return Err(recovery_path_changed(path)); @@ -2918,7 +3165,9 @@ where }; if !final_metadata.is_dir() || !same_recovery_file(&metadata, &final_metadata) - || !same_recovery_file(&metadata, &path_metadata) + || !opened + .same_path_identity(&reopened) + .map_err(|source| recovery_state_io(path, source))? { return Err(recovery_path_changed(path)); } @@ -2930,11 +3179,18 @@ where ), }); } + if let Some(fence) = context.fence.as_deref_mut() { + fence.inputs.push(RecoveryInput::Opened { + path: path.to_owned(), + opened, + }); + } Ok(()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RecoveryPathKind { + File, Symlink, Directory, } @@ -2956,9 +3212,39 @@ fn recovery_path_changed(path: &Path) -> GitError { } } +fn recovery_state_changed(operation: RepositoryOperation, action: RecoveryAction) -> GitError { + GitError::Blocked { + message: format!( + "{} {} is blocked because repository state changed after preview", + operation.label(), + action.label() + ), + } +} + struct RecoveryFile { file: Option, metadata: fs::Metadata, + #[cfg(windows)] + identity: same_file::Handle, +} + +impl RecoveryFile { + #[cfg(windows)] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(self.identity == current.identity) + } + + #[cfg(unix)] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(self.metadata.dev() == current.metadata.dev() + && self.metadata.ino() == current.metadata.ino()) + } + + #[cfg(not(any(unix, windows)))] + fn same_path_identity(&self, current: &Self) -> io::Result { + Ok(same_recovery_file(&self.metadata, ¤t.metadata)) + } } enum RecoveryOpenError { @@ -3064,7 +3350,24 @@ fn open_recovery_file(path: &Path) -> Result { } else { None }; - Ok(RecoveryFile { file, metadata }) + #[cfg(windows)] + let identity = match file.as_ref() { + Some(file) => { + same_file::Handle::from_file(file.try_clone().map_err(RecoveryOpenError::Io)?) + .map_err(RecoveryOpenError::Io)? + } + None => same_file::Handle::from_path(path).map_err(RecoveryOpenError::Io)?, + }; + let metadata = match file.as_ref() { + Some(file) => file.metadata().map_err(RecoveryOpenError::Io)?, + None => metadata, + }; + Ok(RecoveryFile { + file, + metadata, + #[cfg(windows)] + identity, + }) } #[cfg(unix)] @@ -4966,7 +5269,7 @@ mod tests { } #[test] - fn exact_recovery_rejects_index_change_between_complete_final_fingerprints() + fn exact_recovery_rejects_index_change_after_it_is_read_in_final_fingerprint() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; repo.write("conflict.txt", "resolved\n")?; @@ -5002,7 +5305,7 @@ mod tests { Ok(()) }, ) else { - return Err("expected final complete fingerprint to block recovery".into()); + return Err("expected final fingerprint fence to block recovery".into()); }; assert!(error.to_string().contains("state changed after preview")); @@ -5014,6 +5317,57 @@ mod tests { Ok(()) } + #[cfg(windows)] + #[test] + fn recovery_platform_rejects_same_metadata_file_replacement() -> Result<(), Box> { + let repo = TempRepo::new()?; + let input = repo.path().join("index"); + let retained = repo.path().join("index-retained"); + fs::write(&input, b"original")?; + let original_metadata = fs::metadata(&input)?; + let modified = original_metadata.modified()?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut replaced = false; + + let Err(error) = hash_recovery_path_with( + &mut hasher, + b"index", + &input, + Instant::now() + Duration::from_secs(2), + &mut entries, + &mut |path, kind| { + if path == input && kind == RecoveryPathKind::File { + fs::rename(&input, &retained) + .map_err(|source| recovery_state_io(path, source))?; + fs::write(&input, b"replaced") + .map_err(|source| recovery_state_io(path, source))?; + fs::File::open(&input) + .and_then(|file| { + file.set_times(fs::FileTimes::new().set_modified(modified)) + }) + .map_err(|source| recovery_state_io(path, source))?; + replaced = true; + } + Ok(()) + }, + ) else { + return Err("expected same-metadata replacement to be rejected".into()); + }; + + assert!(replaced); + assert!(same_recovery_file( + &original_metadata, + &fs::metadata(&input)? + )); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); + Ok(()) + } + #[test] fn recovery_fingerprint_streams_large_binary_diffs_into_fixed_state() -> Result<(), Box> { @@ -5512,6 +5866,51 @@ mod tests { Ok(()) } + #[cfg(any(unix, windows))] + #[test] + fn recovery_platform_lock_directory_replacement_is_rejected_after_final_validation() + -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let storage = TempRepo::new()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + let lock_directory = storage.path().join(RECOVERY_LOCK_DIRECTORY); + let replaced_directory = storage.path().join("recovery-locks-replaced"); + let concurrent = std::cell::RefCell::new(None); + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + fs::rename(&lock_directory, &replaced_directory) + .map_err(|source| recovery_state_io(&lock_directory, source))?; + fs::create_dir(&lock_directory) + .map_err(|source| recovery_state_io(&lock_directory, source))?; + let lock = git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?; + *concurrent.borrow_mut() = Some(lock); + Ok(()) + }, + ) else { + return Err("expected replaced lock directory to block recovery".into()); + }; + + assert!(concurrent.borrow().is_some()); + assert!(error.to_string().contains("lock identity changed")); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + drop(concurrent.into_inner()); + fs::remove_dir_all(replaced_directory)?; + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_lock_survives_git_directory_replacement() -> Result<(), Box> { From ffad67a0c9b6064f2fee7f27edc6f2b8b1624fe6 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:10:13 -0400 Subject: [PATCH 43/53] fix: permit Windows identity rechecks --- Cargo.lock | 1 + crates/bitbygit-git/Cargo.toml | 1 + crates/bitbygit-git/src/lib.rs | 65 +++++++++++++++++++++------------- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21ff680..8831168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ dependencies = [ "rustix", "same-file", "sha2", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/bitbygit-git/Cargo.toml b/crates/bitbygit-git/Cargo.toml index 1664feb..127073c 100644 --- a/crates/bitbygit-git/Cargo.toml +++ b/crates/bitbygit-git/Cargo.toml @@ -19,3 +19,4 @@ rustix = { version = "0.38", features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] command-group = "5.0" same-file = "1.0" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 881a436..96d39e1 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -18,8 +18,14 @@ use sha2::{Digest, Sha256}; #[cfg(windows)] use command_group::{CommandGroup, GroupChild}; +#[cfg(windows)] +use std::os::windows::fs::OpenOptionsExt; #[cfg(unix)] use std::process::Child; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, +}; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; @@ -2665,11 +2671,11 @@ fn open_recovery_lock_file( ), }); } - fs::OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(windows)] + options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + options .open(path) .map_err(|source| recovery_lock_io(operation, action, source)) } @@ -3333,7 +3339,35 @@ fn open_recovery_file(path: &Path) -> Result { }) } -#[cfg(not(unix))] +#[cfg(windows)] +fn open_recovery_file(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Err(RecoveryOpenError::Missing); + } + Err(source) => return Err(RecoveryOpenError::Io(source)), + }; + if metadata.file_type().is_symlink() { + return Err(RecoveryOpenError::Symlink); + } + let file = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .map_err(RecoveryOpenError::Io)?; + let metadata = file.metadata().map_err(RecoveryOpenError::Io)?; + let identity = same_file::Handle::from_file(file.try_clone().map_err(RecoveryOpenError::Io)?) + .map_err(RecoveryOpenError::Io)?; + Ok(RecoveryFile { + file: Some(file), + metadata, + identity, + }) +} + +#[cfg(not(any(unix, windows)))] fn open_recovery_file(path: &Path) -> Result { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -3350,24 +3384,7 @@ fn open_recovery_file(path: &Path) -> Result { } else { None }; - #[cfg(windows)] - let identity = match file.as_ref() { - Some(file) => { - same_file::Handle::from_file(file.try_clone().map_err(RecoveryOpenError::Io)?) - .map_err(RecoveryOpenError::Io)? - } - None => same_file::Handle::from_path(path).map_err(RecoveryOpenError::Io)?, - }; - let metadata = match file.as_ref() { - Some(file) => file.metadata().map_err(RecoveryOpenError::Io)?, - None => metadata, - }; - Ok(RecoveryFile { - file, - metadata, - #[cfg(windows)] - identity, - }) + Ok(RecoveryFile { file, metadata }) } #[cfg(unix)] From a10367209f4ea88d6ec1ded7441084f6b2ae20e2 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:12:45 -0400 Subject: [PATCH 44/53] test: fix Windows recovery replacement setup --- crates/bitbygit-git/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 96d39e1..af00f7a 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -5359,7 +5359,9 @@ mod tests { .map_err(|source| recovery_state_io(path, source))?; fs::write(&input, b"replaced") .map_err(|source| recovery_state_io(path, source))?; - fs::File::open(&input) + fs::OpenOptions::new() + .write(true) + .open(&input) .and_then(|file| { file.set_times(fs::FileTimes::new().set_modified(modified)) }) @@ -5372,7 +5374,7 @@ mod tests { return Err("expected same-metadata replacement to be rejected".into()); }; - assert!(replaced); + assert!(replaced, "unexpected error: {error}"); assert!(same_recovery_file( &original_metadata, &fs::metadata(&input)? @@ -5806,7 +5808,7 @@ mod tests { Ok(()) } - #[cfg(any(unix, windows))] + #[cfg(unix)] #[test] fn recovery_platform_lock_serializes_bitbygit_execution() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; From a7fb8dbfb26c4b39d11515f17e62181155d0f2b0 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:14:48 -0400 Subject: [PATCH 45/53] test: scope lock directory race to Unix --- crates/bitbygit-git/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index af00f7a..c017500 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -5808,7 +5808,7 @@ mod tests { Ok(()) } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] fn recovery_platform_lock_serializes_bitbygit_execution() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; @@ -5885,7 +5885,7 @@ mod tests { Ok(()) } - #[cfg(any(unix, windows))] + #[cfg(unix)] #[test] fn recovery_platform_lock_directory_replacement_is_rejected_after_final_validation() -> Result<(), Box> { From cbd47eba91bc8d6a41894d4907eb90e200a0ebf5 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:34:07 -0400 Subject: [PATCH 46/53] fix: address recovery safety review --- README.md | 2 +- crates/bitbygit-git/src/lib.rs | 280 +++++++++++++++++++++++++-------- docs/guardrails.md | 13 +- 3 files changed, 225 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 32bf706..2f0d42e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ schema, and safe fallback behavior. Prerequisites: - Rust 1.85 or newer. -- `git` available on `PATH`. +- Git 2.42 or newer available on `PATH` (required for guarded conflict recovery). - `gh` is optional for future GitHub workflows. Run local checks: diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index c017500..530cd50 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -55,12 +55,11 @@ const RECOVERY_PLAN_TIMEOUT: Duration = Duration::from_secs(10); const RECOVERY_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); const RECOVERY_OUTPUT_LIMIT: usize = 256 * 1024; const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; -const RECOVERY_EXECUTION_CAPTURE_LIMIT: usize = 4 * 1024 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; -const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; +const RECOVERY_MINIMUM_GIT_VERSION: (u64, u64) = (2, 42); const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; const RECOVERY_HOOKS: &[&str] = &[ "applypatch-msg", @@ -386,6 +385,7 @@ impl Git { ) -> Result { ensure_recovery_execution_supported(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; + self.ensure_recovery_git_version_until(deadline)?; self.validate_recovery_until(operation, action, deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; @@ -393,6 +393,7 @@ impl Git { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; self.validate_recovery_until(operation, action, deadline)?; + self.ensure_recovery_process_configuration_safe_until(deadline)?; let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action) }) @@ -401,6 +402,7 @@ impl Git { pub fn recovery_state(&self) -> Result { ensure_recovery_planning_supported()?; let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; self.recovery_state_until(deadline) } @@ -412,6 +414,7 @@ impl Git { ) -> Result { ensure_recovery_planning_supported()?; let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; self.validate_recovery_until(operation, action, deadline)?; self.recovery_state_until(deadline) @@ -424,6 +427,7 @@ impl Git { ) -> Result<(), GitError> { ensure_recovery_planning_supported()?; let deadline = Instant::now() + self.recovery_plan_timeout; + self.ensure_recovery_git_version_until(deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; self.validate_recovery_until(operation, action, deadline) } @@ -449,6 +453,7 @@ impl Git { { ensure_recovery_execution_supported(operation, action)?; let deadline = Instant::now() + self.recovery_execution_timeout; + self.ensure_recovery_git_version_until(deadline)?; self.validate_recovery_action(operation, action)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; let recovery_lock = self.acquire_recovery_lock_until(operation, action, deadline)?; @@ -1288,7 +1293,7 @@ impl Git { } fn recovery_state_until(&self, deadline: Instant) -> Result { - self.recovery_state_until_with(deadline, None, &mut None:: Result<(), GitError>>) + self.recovery_state_fenced_until(deadline, || Ok(())) } fn recovery_state_fenced_until( @@ -1352,7 +1357,7 @@ impl Git { "--show-scope".to_owned(), ], ] { - let output = self.run_bounded_git(args.clone(), deadline, true)?; + let output = self.run_bounded_git_digest(args.clone(), deadline)?; if !output.status.success() { return Err(output.git_error(args)); } @@ -1421,22 +1426,17 @@ impl Git { Ok(()) })?; } - let mut ignored_bytes_remaining = Some(RECOVERY_IGNORED_DATA_LIMIT); - for relative in self.ignored_worktree_paths_until(deadline)? { - let path = root.join(&relative); - let mut label = b"ignored-worktree/".to_vec(); - label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); - let mut context = RecoveryHashContext { - deadline, - entries: &mut entries, - follow_symlinks: false, - byte_budget: &mut ignored_bytes_remaining, - byte_budget_error: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit", - fence: fence.as_deref_mut(), - }; - hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { - Ok(()) - })?; + let ignored_args = self.other_worktree_paths_args(true); + let ignored = self.run_bounded_git_digest(ignored_args.clone(), deadline)?; + if !ignored.status.success() { + return Err(ignored.git_error(ignored_args)); + } + hash_field(&mut hasher, &ignored.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args: ignored_args, + digest: ignored.stdout.digest, + }); } for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); @@ -1602,26 +1602,13 @@ impl Git { self.other_worktree_paths_until(false, deadline) } - fn ignored_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { - self.other_worktree_paths_until(true, deadline) - } - fn other_worktree_paths_until( &self, ignored: bool, deadline: Instant, ) -> Result, GitError> { let kind = if ignored { "ignored" } else { "untracked" }; - let mut args = vec!["ls-files".to_owned(), "--others".to_owned()]; - if ignored { - args.push("--ignored".to_owned()); - } - args.extend([ - "--exclude-standard".to_owned(), - "--full-name".to_owned(), - "-z".to_owned(), - "--".to_owned(), - ]); + let args = self.other_worktree_paths_args(ignored); let output = self.run_bounded_git(args.clone(), deadline, true)?; if !output.status.success() { return Err(output.git_error(args)); @@ -1655,6 +1642,29 @@ impl Git { Ok(paths.into_iter().collect()) } + fn other_worktree_paths_args(&self, ignored: bool) -> Vec { + let mut args = vec!["ls-files".to_owned(), "--others".to_owned()]; + if ignored { + args.push("--ignored".to_owned()); + } + args.extend([ + "--exclude-standard".to_owned(), + "--full-name".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ]); + args + } + + fn ensure_recovery_git_version_until(&self, deadline: Instant) -> Result<(), GitError> { + let args = vec!["version".to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + ensure_recovery_git_version(strip_byte_line_ending(&output.stdout.bytes)) + } + fn repository_operation_until( &self, deadline: Instant, @@ -1953,6 +1963,16 @@ impl Git { self.run_bounded_git_with(args, deadline, optional_locks, || Ok(())) } + fn run_bounded_git_digest( + &self, + args: Vec, + deadline: Instant, + ) -> Result { + self.run_bounded_git_with_policy(args, deadline, true, BoundedCommandPolicy::Digest, || { + Ok(()) + }) + } + fn run_bounded_git_with( &self, args: Vec, @@ -1960,6 +1980,25 @@ impl Git { optional_locks: bool, before_spawn: F, ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + let policy = if optional_locks { + BoundedCommandPolicy::Diagnostic + } else { + BoundedCommandPolicy::RecoveryExecution + }; + self.run_bounded_git_with_policy(args, deadline, optional_locks, policy, before_spawn) + } + + fn run_bounded_git_with_policy( + &self, + args: Vec, + deadline: Instant, + optional_locks: bool, + policy: BoundedCommandPolicy, + before_spawn: F, + ) -> Result where F: FnOnce() -> Result<(), GitError>, { @@ -2004,14 +2043,7 @@ impl Git { } else { self.recovery_output_limit }; - run_bounded_command( - command, - args, - deadline, - output_limit, - optional_locks, - before_spawn, - ) + run_bounded_command(command, args, deadline, output_limit, policy, before_spawn) } fn run_args_with_editor( @@ -2241,7 +2273,7 @@ fn run_bounded_command( args: Vec, deadline: Instant, output_limit: usize, - digest_all_output: bool, + policy: BoundedCommandPolicy, before_spawn: F, ) -> Result where @@ -2250,11 +2282,12 @@ where if Instant::now() >= deadline { return Err(GitError::TimedOut { args }); } - let capture_limit = if digest_all_output { + let capture_limit = if policy == BoundedCommandPolicy::Diagnostic { RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT } else { - RECOVERY_EXECUTION_CAPTURE_LIMIT.max(output_limit.saturating_add(1)) + usize::MAX }; + let digest_all_output = policy != BoundedCommandPolicy::RecoveryExecution; command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -2263,6 +2296,9 @@ where return Err(GitError::TimedOut { args }); } before_spawn()?; + if Instant::now() >= deadline { + return Err(GitError::TimedOut { args }); + } let mut child = spawn_contained(command).map_err(|source| GitError::Io { args: args.clone(), source, @@ -2295,7 +2331,7 @@ where let mut output_limit_hit = false; let status = loop { - if capture_exceeded.load(Ordering::Acquire) { + if policy == BoundedCommandPolicy::Diagnostic && capture_exceeded.load(Ordering::Acquire) { output_limit_hit = true; kill_process_tree(&mut child); break child.wait().map_err(|source| GitError::Io { @@ -2310,7 +2346,7 @@ where break status; } let now = Instant::now(); - if now >= deadline { + if policy != BoundedCommandPolicy::RecoveryExecution && now >= deadline { kill_process_tree(&mut child); let _result = child.wait(); kill_process_tree(&mut child); @@ -2318,14 +2354,21 @@ where join_capture_reader(stderr_reader, &args)?; return Err(GitError::TimedOut { args }); } - thread::sleep(Duration::from_millis(5).min(deadline.saturating_duration_since(now))); + let delay = if policy == BoundedCommandPolicy::RecoveryExecution { + Duration::from_millis(5) + } else { + Duration::from_millis(5).min(deadline.saturating_duration_since(now)) + }; + thread::sleep(delay); }; // End the contained process tree before joining the drains so descendants // cannot keep inherited pipe writers open. kill_process_tree(&mut child); let stdout = join_capture_reader(stdout_reader, &args)?; let stderr = join_capture_reader(stderr_reader, &args)?; - if output_limit_hit || capture_exceeded.load(Ordering::Acquire) { + if policy == BoundedCommandPolicy::Diagnostic + && (output_limit_hit || capture_exceeded.load(Ordering::Acquire)) + { return Err(GitError::Blocked { message: format!( "git {} output exceeded the bounded capture limit", @@ -2341,6 +2384,13 @@ where }) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum BoundedCommandPolicy { + Diagnostic, + Digest, + RecoveryExecution, +} + fn drain_bounded_output( mut reader: impl Read, output_limit: usize, @@ -2491,6 +2541,26 @@ fn ensure_recovery_planning_supported() -> Result<(), GitError> { Ok(()) } +fn ensure_recovery_git_version(version: &[u8]) -> Result<(), GitError> { + let version = String::from_utf8_lossy(version); + let parsed = version.strip_prefix("git version ").and_then(|version| { + let mut components = version.split('.'); + Some(( + components.next()?.parse().ok()?, + components.next()?.parse().ok()?, + )) + }); + if parsed.is_some_and(|version| version >= RECOVERY_MINIMUM_GIT_VERSION) { + return Ok(()); + } + Err(GitError::Blocked { + message: format!( + "recovery requires Git {}.{} or newer because exact attribute-state fingerprinting is unavailable in older Git versions; upgrade Git or run recovery manually", + RECOVERY_MINIMUM_GIT_VERSION.0, RECOVERY_MINIMUM_GIT_VERSION.1 + ), + }) +} + #[cfg(not(any(unix, windows)))] fn ensure_recovery_planning_supported() -> Result<(), GitError> { Err(GitError::Blocked { @@ -2911,8 +2981,9 @@ enum RecoveryInput { impl RecoveryInputFence { fn validate(&self, git: &Git, deadline: Instant) -> Result<(), GitError> { + git.ensure_recovery_process_configuration_safe_until(deadline)?; for probe in &self.probes { - let output = git.run_bounded_git(probe.args.clone(), deadline, true)?; + let output = git.run_bounded_git_digest(probe.args.clone(), deadline)?; if !output.status.success() { return Err(output.git_error(probe.args.clone())); } @@ -5063,6 +5134,43 @@ mod tests { Ok(()) } + #[test] + fn recovery_reports_clear_minimum_git_version() -> Result<(), Box> { + let Err(error) = ensure_recovery_git_version(b"git version 2.41.3") else { + return Err("expected old Git to be rejected".into()); + }; + + assert!(error.to_string().contains("requires Git 2.42 or newer")); + ensure_recovery_git_version(b"git version 2.42.0")?; + ensure_recovery_git_version(b"git version 2.55.0.windows.1")?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_execution_is_not_killed_after_spawn_limits() -> Result<(), Box> { + let mut command = Command::new("sh"); + command.args([ + "-c", + "sleep 0.1; i=0; while [ $i -lt 5000 ]; do printf x; i=$((i + 1)); done", + ]); + configure_process_group(&mut command); + + let output = run_bounded_command( + command, + vec!["mutating-recovery-test".to_owned()], + Instant::now() + Duration::from_millis(20), + 32, + BoundedCommandPolicy::RecoveryExecution, + || Ok(()), + )?; + + assert!(output.status.success()); + assert_eq!(output.stdout.bytes.len(), 32); + assert!(output.stdout.truncated); + Ok(()) + } + #[cfg(any(target_os = "macos", windows))] #[test] fn recovery_platform_command_output_is_bounded() -> Result<(), Box> { @@ -5089,7 +5197,7 @@ mod tests { vec!["recovery-output-bound-test".to_owned()], Instant::now() + Duration::from_secs(5), 1024, - false, + BoundedCommandPolicy::Diagnostic, || Ok(()), ) else { return Err("expected oversized recovery output to be stopped".into()); @@ -5155,7 +5263,7 @@ mod tests { } #[test] - fn exact_rebase_abort_rejects_changed_ignored_collision() -> Result<(), Box> { + fn exact_rebase_abort_rejects_changed_ignored_collision_path() -> Result<(), Box> { let repo = initialized_repo()?; repo.write(".gitignore", "target/\n")?; repo.run(["add", ".gitignore"])?; @@ -5189,7 +5297,10 @@ mod tests { repo.write("target/victim.bin", "at preview\n")?; let git = Git::new(repo.path()); let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - repo.write("target/victim.bin", "changed after preview\n")?; + fs::rename( + repo.path().join("target/victim.bin"), + repo.path().join("target/replacement.bin"), + )?; let Err(error) = git.recover_exact( RepositoryOperation::Rebase, @@ -5201,8 +5312,8 @@ mod tests { assert!(error.to_string().contains("state changed after preview")); assert_eq!( - fs::read_to_string(repo.path().join("target/victim.bin"))?, - "changed after preview\n" + fs::read_to_string(repo.path().join("target/replacement.bin"))?, + "at preview\n" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) @@ -5250,17 +5361,58 @@ mod tests { } #[test] - fn recovery_fingerprint_bounds_ignored_data() -> Result<(), Box> { + fn recovery_fingerprint_streams_ignored_paths_without_reading_data() + -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - repo.write(".gitignore", "large.ignored\n")?; + repo.write(".gitignore", "large.ignored\ntarget/\n")?; let ignored = fs::File::create(repo.path().join("large.ignored"))?; - ignored.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; + ignored.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; + fs::create_dir(repo.path().join("target"))?; + for index in 0..4_000 { + repo.write(&format!("target/generated-artifact-{index:04}.bin"), "")?; + } - let Err(error) = Git::new(repo.path()).recovery_state() else { - return Err("expected oversized ignored data to block planning".into()); + let git = Git::new(repo.path()); + let baseline = git.recovery_state()?; + ignored.set_len(RECOVERY_UNTRACKED_DATA_LIMIT * 2)?; + + assert_eq!(git.recovery_state()?, baseline); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn recovery_preview_rechecks_process_safety_inside_state_fence() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let (repo, _original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let hook = repo.path().join(".git/hooks/commit-msg"); + + let Err(error) = + git.recovery_state_fenced_until(Instant::now() + Duration::from_secs(10), || { + fs::write(&hook, "#!/bin/sh\nexit 0\n").map_err(|source| GitError::Io { + args: vec!["install synchronized hook".to_owned()], + source, + })?; + let mut permissions = fs::metadata(&hook) + .map_err(|source| GitError::Io { + args: vec!["inspect synchronized hook".to_owned()], + source, + })? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions).map_err(|source| GitError::Io { + args: vec!["enable synchronized hook".to_owned()], + source, + }) + }) + else { + return Err("expected a hook enabled during fingerprinting to be rejected".into()); }; - assert!(error.to_string().contains("ignored worktree data exceeds")); + assert!(error.to_string().contains("executable Git hooks")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -5273,7 +5425,7 @@ mod tests { let (repo, _original_head) = prepare_merge_conflict()?; let external = TempRepo::new()?; let external_data = fs::File::create(external.path().join("large.bin"))?; - external_data.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; + external_data.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; repo.write(".gitignore", "ignored-link\n")?; symlink(external.path(), repo.path().join("ignored-link"))?; let git = Git::new(repo.path()); @@ -5678,7 +5830,7 @@ mod tests { vec!["descendant-timeout-test".to_owned()], Instant::now() + Duration::from_millis(100), 4096, - false, + BoundedCommandPolicy::Diagnostic, || Ok(()), ) else { return Err("expected descendant command to time out".into()); @@ -5706,7 +5858,7 @@ mod tests { vec!["windows-job-timeout-test".to_owned()], Instant::now() + Duration::from_millis(100), 4096, - false, + BoundedCommandPolicy::Diagnostic, || Ok(()), ) else { return Err("expected Windows descendant command to time out".into()); diff --git a/docs/guardrails.md b/docs/guardrails.md index 41230d8..b840e01 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -123,7 +123,8 @@ Conflict mode should: - audit conflict recovery actions Conflict recovery actions are high risk because they move repository state. -BitByGit fingerprints bounded tracked, untracked, ignored, control, +BitByGit fingerprints bounded tracked and untracked data plus the complete +ignored path set, control, configuration, attribute, hook, and ref inputs at preview. It takes an identity-checked lock in app-owned data storage keyed by the canonical repository root, checks that fingerprint as the last operation before spawning Git, and @@ -136,13 +137,15 @@ clients and filesystem writers do not honor an application lock; their normal concurrency boundary still applies, and BitByGit surfaces resulting Git failures rather than claiming to exclude those writers. -Recovery rejects executable hooks, active external filters or merge drivers, +Recovery requires Git 2.42 or newer for exact system and global attribute-path +discovery. It rejects executable hooks, active external filters or merge drivers, fsmonitor and signing processes, non-built-in persisted rebase strategies, and remaining rebase `exec` commands. This prevents those configured processes from detaching outside bounded cleanup. Planning also fails closed when its path, -entry, ignored-data, or output limits are exceeded. Linux uses sealed capture -files, macOS uses bounded temporary captures, Unix places Git in a process group, -and Windows uses bounded temporary captures plus a kill-on-close Job Object. +entry, untracked-data, or diagnostic output limits are exceeded. Linux uses +sealed capture files, macOS uses bounded temporary captures, Unix places Git in +a process group, and Windows uses bounded temporary captures plus a kill-on-close +Job Object. ## Confirmation Copy From ea000997478fa509b2fe43a8dfe3dccd3b91b5e0 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:40:51 -0400 Subject: [PATCH 47/53] test: restore recovery platform coverage --- crates/bitbygit-git/src/lib.rs | 49 ++++++++++++++++++++++++++-------- crates/bitbygit-tui/src/lib.rs | 24 ++++++++--------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 530cd50..caa2239 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1381,10 +1381,10 @@ impl Git { &mut entries, fence.as_deref_mut(), )?; - if *relative == "index" - && let Some(after_index) = after_index.take() - { - after_index()?; + if *relative == "index" { + if let Some(after_index) = after_index.take() { + after_index()?; + } } } let hooks = self.recovery_hooks_path_until(deadline)?; @@ -5186,30 +5186,57 @@ mod tests { command.args([ "-NoProfile", "-Command", - "[Console]::Out.Write('x' * 5000000)", + "[Console]::Out.Write('x' * 70000000)", ]); command }; configure_process_group(&mut command); - let Err(error) = run_bounded_command( + let result = run_bounded_command( command, vec!["recovery-output-bound-test".to_owned()], Instant::now() + Duration::from_secs(5), 1024, BoundedCommandPolicy::Diagnostic, || Ok(()), - ) else { - return Err("expected oversized recovery output to be stopped".into()); - }; + ); assert!( - error.to_string().contains("bounded capture limit"), - "unexpected error: {error}" + matches!(result, Err(GitError::TimedOut { .. })) + || result + .as_ref() + .is_err_and(|error| error.to_string().contains("bounded capture limit")), + "unexpected result: {result:?}" ); Ok(()) } + #[cfg(windows)] + #[test] + fn recovery_platform_execution_is_not_killed_after_spawn_limits() -> Result<(), Box> + { + let mut command = Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + "[Console]::Out.Write('x' * 5000000); Start-Sleep -Milliseconds 200", + ]); + + let output = run_bounded_command( + command, + vec!["mutating-recovery-test".to_owned()], + Instant::now() + Duration::from_millis(100), + 32, + BoundedCommandPolicy::RecoveryExecution, + || Ok(()), + )?; + + assert!(output.status.success()); + assert_eq!(output.stdout.bytes.len(), 32); + assert!(output.stdout.truncated); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index f9d4145..975dd79 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -2768,19 +2768,19 @@ fn validate_conflict_mode(git: &Git, requests: &[OperationRequest]) -> Result<() }; for request in requests { - if let OperationRequest::Recover(recovery) = request - && operation != Some(recovery_git_operation(*recovery)) - { - return Err(recovery_state_error(*recovery, operation)); + if let OperationRequest::Recover(recovery) = request { + if operation != Some(recovery_git_operation(*recovery)) { + return Err(recovery_state_error(*recovery, operation)); + } } - if let Some(active) = operation - && !conflict_mode_allows(request) - { - return Err(format!( - "{} blocked: an active {} must be resolved first.", - request_action_label(request), - operation_label(active).to_ascii_lowercase() - )); + if let Some(active) = operation { + if !conflict_mode_allows(request) { + return Err(format!( + "{} blocked: an active {} must be resolved first.", + request_action_label(request), + operation_label(active).to_ascii_lowercase() + )); + } } } Ok(()) From 5891bf991bf27de86e5f1a3e0fd167c932d9ac36 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 17:50:44 -0400 Subject: [PATCH 48/53] fix: fingerprint ignored recovery inputs --- crates/bitbygit-git/src/lib.rs | 60 ++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index caa2239..796cd81 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -58,6 +58,7 @@ const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; +const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; const RECOVERY_MINIMUM_GIT_VERSION: (u64, u64) = (2, 42); const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; @@ -1426,17 +1427,22 @@ impl Git { Ok(()) })?; } - let ignored_args = self.other_worktree_paths_args(true); - let ignored = self.run_bounded_git_digest(ignored_args.clone(), deadline)?; - if !ignored.status.success() { - return Err(ignored.git_error(ignored_args)); - } - hash_field(&mut hasher, &ignored.stdout.digest); - if let Some(fence) = fence.as_deref_mut() { - fence.probes.push(RecoveryProbe { - args: ignored_args, - digest: ignored.stdout.digest, - }); + let mut ignored_bytes_remaining = Some(RECOVERY_IGNORED_DATA_LIMIT); + for relative in self.ignored_worktree_paths_until(deadline)? { + let path = root.join(&relative); + let mut label = b"ignored-worktree/".to_vec(); + label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); + let mut context = RecoveryHashContext { + deadline, + entries: &mut entries, + follow_symlinks: false, + byte_budget: &mut ignored_bytes_remaining, + byte_budget_error: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit", + fence: fence.as_deref_mut(), + }; + hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { + Ok(()) + })?; } for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); @@ -1602,6 +1608,10 @@ impl Git { self.other_worktree_paths_until(false, deadline) } + fn ignored_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { + self.other_worktree_paths_until(true, deadline) + } + fn other_worktree_paths_until( &self, ignored: bool, @@ -5324,10 +5334,7 @@ mod tests { repo.write("target/victim.bin", "at preview\n")?; let git = Git::new(repo.path()); let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - fs::rename( - repo.path().join("target/victim.bin"), - repo.path().join("target/replacement.bin"), - )?; + repo.write("target/victim.bin", "changed after preview\n")?; let Err(error) = git.recover_exact( RepositoryOperation::Rebase, @@ -5339,8 +5346,8 @@ mod tests { assert!(error.to_string().contains("state changed after preview")); assert_eq!( - fs::read_to_string(repo.path().join("target/replacement.bin"))?, - "at preview\n" + fs::read_to_string(repo.path().join("target/victim.bin"))?, + "changed after preview\n" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) @@ -5388,22 +5395,17 @@ mod tests { } #[test] - fn recovery_fingerprint_streams_ignored_paths_without_reading_data() - -> Result<(), Box> { + fn recovery_fingerprint_bounds_ignored_data() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - repo.write(".gitignore", "large.ignored\ntarget/\n")?; + repo.write(".gitignore", "large.ignored\n")?; let ignored = fs::File::create(repo.path().join("large.ignored"))?; - ignored.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; - fs::create_dir(repo.path().join("target"))?; - for index in 0..4_000 { - repo.write(&format!("target/generated-artifact-{index:04}.bin"), "")?; - } + ignored.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; - let git = Git::new(repo.path()); - let baseline = git.recovery_state()?; - ignored.set_len(RECOVERY_UNTRACKED_DATA_LIMIT * 2)?; + let Err(error) = Git::new(repo.path()).recovery_state() else { + return Err("expected oversized ignored data to block planning".into()); + }; - assert_eq!(git.recovery_state()?, baseline); + assert!(error.to_string().contains("ignored worktree data exceeds")); Ok(()) } From dab7f2130f1281a316f44ee62410b59259988a5c Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 18:08:10 -0400 Subject: [PATCH 49/53] fix: scope recovery safety fences --- crates/bitbygit-git/src/lib.rs | 333 +++++++++++++++++++++++++++------ 1 file changed, 276 insertions(+), 57 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 796cd81..94400fe 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -58,7 +58,6 @@ const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; const RECOVERY_STATE_ENTRY_LIMIT: usize = 100_000; const RECOVERY_UNTRACKED_DATA_LIMIT: u64 = 64 * 1024 * 1024; -const RECOVERY_IGNORED_DATA_LIMIT: u64 = 64 * 1024 * 1024; const RECOVERY_REBASE_TODO_LIMIT: u64 = 1024 * 1024; const RECOVERY_MINIMUM_GIT_VERSION: (u64, u64) = (2, 42); const RECOVERY_LOCK_DIRECTORY: &str = "recovery-locks"; @@ -394,7 +393,7 @@ impl Git { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; self.validate_recovery_until(operation, action, deadline)?; - self.ensure_recovery_process_configuration_safe_until(deadline)?; + self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(()))?; let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action) }) @@ -418,7 +417,7 @@ impl Git { self.ensure_recovery_git_version_until(deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; self.validate_recovery_until(operation, action, deadline)?; - self.recovery_state_until(deadline) + self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(())) } pub fn validate_recovery( @@ -461,8 +460,12 @@ impl Git { self.run_recovery_args_until_with(operation, action, deadline, || { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; - let current_state = match self.recovery_state_fenced_until(deadline, before_spawn_check) - { + let current_state = match self.recovery_state_fenced_for_until( + operation, + action, + deadline, + before_spawn_check, + ) { Ok(state) => state, Err(GitError::Blocked { message }) if message.contains("changed while it was fingerprinted") => @@ -487,7 +490,9 @@ impl Git { action: RecoveryAction, deadline: Instant, ) -> Result<(), GitError> { - if self.recovery_state_until(deadline)? == *expected_state { + if self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(()))? + == *expected_state + { return Ok(()); } Err(recovery_state_changed(operation, action)) @@ -1302,16 +1307,42 @@ impl Git { deadline: Instant, after_index: F, ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + self.recovery_state_fenced_until_with(None, deadline, after_index) + } + + fn recovery_state_fenced_for_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + after_index: F, + ) -> Result + where + F: FnOnce() -> Result<(), GitError>, + { + self.recovery_state_fenced_until_with(Some((operation, action)), deadline, after_index) + } + + fn recovery_state_fenced_until_with( + &self, + recovery: Option<(RepositoryOperation, RecoveryAction)>, + deadline: Instant, + after_index: F, + ) -> Result where F: FnOnce() -> Result<(), GitError>, { let mut fence = RecoveryInputFence::default(); let mut after_index = Some(after_index); - self.recovery_state_until_with(deadline, Some(&mut fence), &mut after_index) + self.recovery_state_until_with(recovery, deadline, Some(&mut fence), &mut after_index) } fn recovery_state_until_with( &self, + recovery: Option<(RepositoryOperation, RecoveryAction)>, deadline: Instant, mut fence: Option<&mut RecoveryInputFence>, after_index: &mut Option, @@ -1427,22 +1458,16 @@ impl Git { Ok(()) })?; } - let mut ignored_bytes_remaining = Some(RECOVERY_IGNORED_DATA_LIMIT); - for relative in self.ignored_worktree_paths_until(deadline)? { - let path = root.join(&relative); - let mut label = b"ignored-worktree/".to_vec(); - label.extend_from_slice(relative.as_os_str().as_encoded_bytes()); - let mut context = RecoveryHashContext { + if let Some((operation, action)) = recovery { + hash_field(&mut hasher, operation.label().as_bytes()); + hash_field(&mut hasher, action.label().as_bytes()); + self.ensure_no_ignored_recovery_collisions_until( + operation, + action, deadline, - entries: &mut entries, - follow_symlinks: false, - byte_budget: &mut ignored_bytes_remaining, - byte_budget_error: "recovery planning is blocked because ignored worktree data exceeds the 64 MiB fingerprint limit", - fence: fence.as_deref_mut(), - }; - hash_recovery_path_inner(&mut hasher, &label, &path, 0, &mut context, &mut |_, _| { - Ok(()) - })?; + &mut hasher, + fence.as_deref_mut(), + )?; } for relative in self.worktree_attributes_until(deadline)? { let mut label = b"worktree-attributes/".to_vec(); @@ -1608,10 +1633,6 @@ impl Git { self.other_worktree_paths_until(false, deadline) } - fn ignored_worktree_paths_until(&self, deadline: Instant) -> Result, GitError> { - self.other_worktree_paths_until(true, deadline) - } - fn other_worktree_paths_until( &self, ignored: bool, @@ -1666,6 +1687,161 @@ impl Git { args } + fn ensure_no_ignored_recovery_collisions_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + hasher: &mut Sha256, + mut fence: Option<&mut RecoveryInputFence>, + ) -> Result<(), GitError> { + let Some(target) = self.recovery_overwrite_target_until(operation, action, deadline)? + else { + return Ok(()); + }; + let changed_args = vec![ + "diff".to_owned(), + "--name-only".to_owned(), + "--no-renames".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "-z".to_owned(), + "HEAD".to_owned(), + target, + "--".to_owned(), + ]; + let changed = self.run_bounded_git(changed_args.clone(), deadline, true)?; + if !changed.status.success() { + return Err(changed.git_error(changed_args)); + } + if changed.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because recovery target paths exceed the bounded output limit" + .to_owned(), + }); + } + hash_field(hasher, &changed.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args: changed_args, + digest: changed.stdout.digest, + }); + } + + let mut candidates = BTreeSet::new(); + for path in changed.stdout.bytes.split(|byte| *byte == 0) { + if path.is_empty() { + continue; + } + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid recovery target path" + .to_owned(), + }); + } + candidates.insert(path); + } + + let mut collisions = BTreeSet::new(); + for chunk in candidates.into_iter().collect::>().chunks(128) { + let mut args = vec![ + "ls-files".to_owned(), + "--others".to_owned(), + "--ignored".to_owned(), + "--exclude-standard".to_owned(), + "--full-name".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ]; + for path in chunk { + let Some(path) = path.to_str() else { + return Err(GitError::Blocked { + message: "recovery planning is blocked because a recovery target path is not valid UTF-8" + .to_owned(), + }); + }; + args.push(format!(":(top,literal){path}")); + } + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because relevant ignored paths exceed the bounded output limit" + .to_owned(), + }); + } + hash_field(hasher, &output.stdout.digest); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + }); + } + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if !path.is_empty() { + collisions.insert(path_from_bytes(path)); + } + } + } + + if let Some(path) = collisions.first() { + return Err(GitError::Blocked { + message: format!( + "{} {} is blocked because ignored worktree path {} would be overwritten; move or remove the path, preview recovery again, or run Git manually", + operation.label(), + action.label(), + path.display() + ), + }); + } + Ok(()) + } + + fn recovery_overwrite_target_until( + &self, + operation: RepositoryOperation, + action: RecoveryAction, + deadline: Instant, + ) -> Result, GitError> { + match (operation, action) { + (RepositoryOperation::Merge, RecoveryAction::Continue) => Ok(None), + (RepositoryOperation::Merge, RecoveryAction::Abort) => Ok(Some("ORIG_HEAD".to_owned())), + (RepositoryOperation::Merge, RecoveryAction::Skip) => Ok(None), + (RepositoryOperation::Rebase, _) => { + for relative in ["rebase-merge/orig-head", "rebase-apply/orig-head"] { + let path = self.git_path_until(relative, deadline)?; + let Some(value) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + let oid = value.trim(); + if matches!(oid.len(), 40 | 64) + && oid.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Ok(Some(oid.to_owned())); + } + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {relative} does not contain a valid object ID" + ), + }); + } + Err(GitError::Blocked { + message: "recovery planning is blocked because the original rebase target is unavailable" + .to_owned(), + }) + } + } + } + fn ensure_recovery_git_version_until(&self, deadline: Instant) -> Result<(), GitError> { let args = vec!["version".to_owned()]; let output = self.run_bounded_git(args.clone(), deadline, true)?; @@ -3148,6 +3324,7 @@ where let mut opened = opened; let metadata = opened.metadata.clone(); *context.entries += 1; + ensure_recovery_hook_observation_safe(label, &metadata)?; hash_field(hasher, &metadata.len().to_le_bytes()); #[cfg(unix)] hash_field(hasher, &metadata.mode().to_le_bytes()); @@ -4270,6 +4447,30 @@ fn hook_is_enabled(path: &Path) -> bool { .unwrap_or(false) } +fn ensure_recovery_hook_observation_safe( + label: &[u8], + metadata: &fs::Metadata, +) -> Result<(), GitError> { + let Some(hook) = RECOVERY_HOOKS + .iter() + .find(|hook| label.strip_prefix(b"hooks/") == Some(hook.as_bytes())) + else { + return Ok(()); + }; + #[cfg(unix)] + let enabled = metadata.is_file() && metadata.mode() & 0o111 != 0; + #[cfg(not(unix))] + let enabled = metadata.is_file(); + if enabled { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because executable Git hooks may start uncontained processes: {hook}; disable them and preview recovery again, or run Git manually" + ), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -5300,7 +5501,7 @@ mod tests { } #[test] - fn exact_rebase_abort_rejects_changed_ignored_collision_path() -> Result<(), Box> { + fn rebase_abort_blocks_ignored_collision_path() -> Result<(), Box> { let repo = initialized_repo()?; repo.write(".gitignore", "target/\n")?; repo.run(["add", ".gitignore"])?; @@ -5333,21 +5534,15 @@ mod tests { fs::create_dir(repo.path().join("target"))?; repo.write("target/victim.bin", "at preview\n")?; let git = Git::new(repo.path()); - let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; - repo.write("target/victim.bin", "changed after preview\n")?; - - let Err(error) = git.recover_exact( - RepositoryOperation::Rebase, - RecoveryAction::Abort, - &expected, - ) else { - return Err("expected changed ignored collision to block abort".into()); + let Err(error) = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort) + else { + return Err("expected ignored collision to block abort planning".into()); }; - assert!(error.to_string().contains("state changed after preview")); + assert!(error.to_string().contains("would be overwritten")); assert_eq!( fs::read_to_string(repo.path().join("target/victim.bin"))?, - "changed after preview\n" + "at preview\n" ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); Ok(()) @@ -5395,17 +5590,25 @@ mod tests { } #[test] - fn recovery_fingerprint_bounds_ignored_data() -> Result<(), Box> { + fn exact_merge_abort_allows_large_unrelated_ignored_build_tree() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; - repo.write(".gitignore", "large.ignored\n")?; - let ignored = fs::File::create(repo.path().join("large.ignored"))?; - ignored.set_len(RECOVERY_IGNORED_DATA_LIMIT + 1)?; + repo.write(".gitignore", "target/\n")?; + fs::create_dir(repo.path().join("target"))?; + let artifact = fs::File::create(repo.path().join("target/large-artifact.bin"))?; + artifact.set_len(RECOVERY_UNTRACKED_DATA_LIMIT * 2)?; + for index in 0..4_000 { + repo.write(&format!("target/generated-artifact-{index:04}.bin"), "")?; + } + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; - let Err(error) = Git::new(repo.path()).recovery_state() else { - return Err("expected oversized ignored data to block planning".into()); - }; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; - assert!(error.to_string().contains("ignored worktree data exceeds")); + assert_eq!(git.status()?.operation, None); + assert_eq!( + artifact.metadata()?.len(), + RECOVERY_UNTRACKED_DATA_LIMIT * 2 + ); Ok(()) } @@ -5445,24 +5648,40 @@ mod tests { Ok(()) } - #[cfg(target_os = "linux")] + #[cfg(unix)] #[test] - fn recovery_fingerprint_does_not_follow_ignored_directory_symlinks() + fn recovery_fingerprint_rejects_swapped_executable_hook_observation() -> Result<(), Box> { - use std::os::unix::fs::symlink; + use std::os::unix::fs::PermissionsExt; let (repo, _original_head) = prepare_merge_conflict()?; - let external = TempRepo::new()?; - let external_data = fs::File::create(external.path().join("large.bin"))?; - external_data.set_len(RECOVERY_UNTRACKED_DATA_LIMIT + 1)?; - repo.write(".gitignore", "ignored-link\n")?; - symlink(external.path(), repo.path().join("ignored-link"))?; + let active = repo.path().join("active-hooks"); + let safe = repo.path().join("safe-hooks"); + let unsafe_hooks = repo.path().join("unsafe-hooks"); + fs::create_dir(&active)?; + fs::create_dir(&unsafe_hooks)?; + let marker = repo.path().join("swapped-hook-ran"); + let hook = unsafe_hooks.join("commit-msg"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display()))?; + let mut permissions = fs::metadata(&hook)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions)?; + repo.run(["config", "core.hooksPath", "active-hooks"])?; let git = Git::new(repo.path()); + let deadline = Instant::now() + Duration::from_secs(10); + git.ensure_recovery_process_configuration_safe_until(deadline)?; - let baseline = git.recovery_state()?; - fs::write(external.path().join("new-data"), "outside repository\n")?; + let Err(error) = git.recovery_state_fenced_until(deadline, || { + fs::rename(&active, &safe).map_err(|source| recovery_state_io(&active, source))?; + fs::rename(&unsafe_hooks, &active) + .map_err(|source| recovery_state_io(&unsafe_hooks, source)) + }) else { + return Err("expected swapped executable hook directory to fail closed".into()); + }; - assert_eq!(git.recovery_state()?, baseline); + assert!(error.to_string().contains("executable Git hooks")); + assert!(!marker.exists(), "swapped hook process started"); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } From cd9f6a0609a98683e480e85f6b305bb85cbc836f Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 18:31:15 -0400 Subject: [PATCH 50/53] fix: close recovery execution races --- crates/bitbygit-git/src/lib.rs | 767 +++++++++++++++++++++++++++++---- 1 file changed, 679 insertions(+), 88 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 94400fe..453e990 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -393,9 +393,11 @@ impl Git { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; self.validate_recovery_until(operation, action, deadline)?; - self.recovery_state_fenced_for_until(operation, action, deadline, || Ok(()))?; - let repository_root = self.canonical_repository_root_until(deadline)?; - recovery_lock.ensure_identity(&repository_root, operation, action) + self.recovery_state_fenced_for_until(operation, action, deadline, || { + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) + })?; + Ok(()) }) } @@ -460,25 +462,24 @@ impl Git { self.run_recovery_args_until_with(operation, action, deadline, || { let repository_root = self.canonical_repository_root_until(deadline)?; recovery_lock.ensure_identity(&repository_root, operation, action)?; - let current_state = match self.recovery_state_fenced_for_until( - operation, - action, - deadline, - before_spawn_check, - ) { - Ok(state) => state, - Err(GitError::Blocked { message }) - if message.contains("changed while it was fingerprinted") => - { - return Err(recovery_state_changed(operation, action)); - } - Err(error) => return Err(error), - }; + let current_state = + match self.recovery_state_fenced_for_until(operation, action, deadline, || { + before_spawn_check()?; + let repository_root = self.canonical_repository_root_until(deadline)?; + recovery_lock.ensure_identity(&repository_root, operation, action) + }) { + Ok(state) => state, + Err(GitError::Blocked { message }) + if message.contains("changed while it was fingerprinted") => + { + return Err(recovery_state_changed(operation, action)); + } + Err(error) => return Err(error), + }; if current_state != *expected_state { return Err(recovery_state_changed(operation, action)); } - let repository_root = self.canonical_repository_root_until(deadline)?; - recovery_lock.ensure_identity(&repository_root, operation, action) + Ok(()) }) } @@ -1351,6 +1352,11 @@ impl Git { F: FnOnce() -> Result<(), GitError>, { let mut hasher = Sha256::new(); + self.hash_recovery_process_configuration_until( + deadline, + &mut hasher, + fence.as_deref_mut(), + )?; for args in [ vec![ "status".to_owned(), @@ -1398,6 +1404,8 @@ impl Git { fence.probes.push(RecoveryProbe { args, digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, }); } } @@ -1413,13 +1421,9 @@ impl Git { &mut entries, fence.as_deref_mut(), )?; - if *relative == "index" { - if let Some(after_index) = after_index.take() { - after_index()?; - } - } } - let hooks = self.recovery_hooks_path_until(deadline)?; + let hooks = + self.recovery_hooks_path_fenced_until(deadline, &mut hasher, fence.as_deref_mut())?; hash_recovery_path_fenced( &mut hasher, b"hooks", @@ -1484,6 +1488,10 @@ impl Git { if let Some(fence) = fence { fence.validate(self, deadline)?; + if let Some(after_index) = after_index.take() { + after_index()?; + } + fence.validate(self, deadline)?; } Ok(RecoveryState { @@ -1491,6 +1499,49 @@ impl Git { }) } + fn hash_recovery_process_configuration_until( + &self, + deadline: Instant, + hasher: &mut Sha256, + mut fence: Option<&mut RecoveryInputFence>, + ) -> Result<(), GitError> { + for key in ["core.fsmonitor", "commit.gpgsign", "tag.gpgsign"] { + let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + let safety = RecoveryProbeSafety::DisabledConfig(key); + ensure_recovery_probe_observation_safe(safety, &args, &output)?; + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety, + }); + } + } + + let args = vec![ + "config".to_owned(), + "--name-only".to_owned(), + "--null".to_owned(), + "--list".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + let safety = RecoveryProbeSafety::ExternalDrivers; + ensure_recovery_probe_observation_safe(safety, &args, &output)?; + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety, + }); + } + Ok(()) + } + fn ensure_recovery_process_configuration_safe_until( &self, deadline: Instant, @@ -1725,6 +1776,8 @@ impl Git { fence.probes.push(RecoveryProbe { args: changed_args, digest: changed.stdout.digest, + status: changed.status.code(), + safety: RecoveryProbeSafety::None, }); } @@ -1747,6 +1800,50 @@ impl Git { candidates.insert(path); } + if operation == RepositoryOperation::Rebase && action != RecoveryAction::Abort { + let remaining_oids = match fence.as_deref() { + Some(fence) => fence.remaining_rebase_commit_oids.clone(), + None => self.remaining_rebase_commit_oids_until(deadline)?, + }; + for oid in remaining_oids { + let args = vec![ + "show".to_owned(), + "--format=".to_owned(), + "--name-only".to_owned(), + "--no-renames".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "-z".to_owned(), + oid, + "--".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if !output.status.success() { + return Err(output.git_error(args)); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because an intermediate rebase commit touches too many paths" + .to_owned(), + }); + } + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence.as_deref_mut() { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } + for path in output.stdout.bytes.split(|byte| *byte == 0) { + if !path.is_empty() { + candidates.insert(checked_recovery_target_path(path)?); + } + } + } + } + let mut collisions = BTreeSet::new(); for chunk in candidates.into_iter().collect::>().chunks(128) { let mut args = vec![ @@ -1782,6 +1879,8 @@ impl Git { fence.probes.push(RecoveryProbe { args, digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, }); } for path in output.stdout.bytes.split(|byte| *byte == 0) { @@ -1804,6 +1903,25 @@ impl Git { Ok(()) } + fn remaining_rebase_commit_oids_until( + &self, + deadline: Instant, + ) -> Result, GitError> { + for relative in [ + "rebase-merge/git-rebase-todo", + "rebase-apply/git-rebase-todo", + ] { + let path = self.git_path_until(relative, deadline)?; + let Some(contents) = + read_bounded_optional_file(&path, RECOVERY_REBASE_TODO_LIMIT, deadline)? + else { + continue; + }; + return parse_remaining_rebase_commit_oids(relative, &contents); + } + Ok(BTreeSet::new()) + } + fn recovery_overwrite_target_until( &self, operation: RepositoryOperation, @@ -1934,6 +2052,48 @@ impl Git { Err(output.git_error(args)) } + fn recovery_hooks_path_fenced_until( + &self, + deadline: Instant, + hasher: &mut Sha256, + fence: Option<&mut RecoveryInputFence>, + ) -> Result { + let args = vec![ + "config".to_owned(), + "--path".to_owned(), + "--get".to_owned(), + "core.hooksPath".to_owned(), + ]; + let output = self.run_bounded_git(args.clone(), deadline, true)?; + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery planning is blocked because core.hooksPath is too long" + .to_owned(), + }); + } + if !output.status.success() && output.status.code() != Some(1) { + return Err(output.git_error(args)); + } + hash_recovery_probe(hasher, &output); + if let Some(fence) = fence { + fence.probes.push(RecoveryProbe { + args, + digest: output.stdout.digest, + status: output.status.code(), + safety: RecoveryProbeSafety::None, + }); + } + if output.status.success() { + let path = path_from_bytes(strip_byte_line_ending(&output.stdout.bytes)); + return Ok(if path.is_absolute() { + path + } else { + self.repo_root_until(deadline)?.join(path) + }); + } + self.git_path_until("hooks", deadline) + } + fn git_var_path_until( &self, variable: &str, @@ -2211,6 +2371,9 @@ impl Git { } if optional_locks { command.env("GIT_OPTIONAL_LOCKS", "0"); + if args.first().is_none_or(|arg| arg != "config") { + command.args(["-c", "core.fsmonitor=false"]); + } } else { command.args(["-c", "maintenance.auto=false", "-c", "gc.auto=0"]); } @@ -2454,6 +2617,130 @@ fn is_recovery_external_driver_config(name: &[u8]) -> bool { || (name.starts_with("merge.") && name.ends_with(".driver")) } +fn hash_recovery_probe(hasher: &mut Sha256, output: &BoundedCommandOutput) { + hash_field( + hasher, + &output.status.code().unwrap_or(i32::MIN).to_le_bytes(), + ); + hash_field(hasher, &output.stdout.digest); +} + +fn ensure_recovery_probe_observation_safe( + safety: RecoveryProbeSafety, + args: &[String], + output: &BoundedCommandOutput, +) -> Result<(), GitError> { + match safety { + RecoveryProbeSafety::None => { + if !output.status.success() && output.status.code() != Some(1) { + return Err(output.clone().git_error(args.to_vec())); + } + } + RecoveryProbeSafety::DisabledConfig(key) => { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: format!("recovery is blocked because {key} is too long"), + }); + } + if output.status.code() == Some(1) { + return Ok(()); + } + if !output.status.success() { + return Err(output.clone().git_error(args.to_vec())); + } + let value = String::from_utf8_lossy(strip_byte_line_ending(&output.stdout.bytes)); + if !matches!(value.trim(), "" | "0" | "false" | "no" | "off") { + let risk = if key == "core.fsmonitor" { + "an uncontained process" + } else { + "an uncontained signing process" + }; + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because {key} may start {risk}; disable it and preview recovery again, or run Git manually" + ), + }); + } + } + RecoveryProbeSafety::ExternalDrivers => { + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" + .to_owned(), + }); + } + if !output.status.success() { + return Err(output.clone().git_error(args.to_vec())); + } + let names = output + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|name| is_recovery_external_driver_config(name)) + .map(|name| String::from_utf8_lossy(name).into_owned()) + .collect::>(); + if !names.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because configured external Git drivers may be activated by a recovery target tree and start uncontained processes: {}; remove them from every Git config scope, preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } + } + } + Ok(()) +} + +fn checked_recovery_target_path(path: &[u8]) -> Result { + let path = path_from_bytes(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(GitError::Blocked { + message: "recovery planning is blocked by an invalid recovery target path".to_owned(), + }); + } + Ok(path) +} + +fn parse_remaining_rebase_commit_oids( + relative: &str, + contents: &str, +) -> Result, GitError> { + let mut oids = BTreeSet::new(); + for line in contents.lines() { + let mut fields = line.split_ascii_whitespace(); + let Some(command) = fields.next() else { + continue; + }; + let oid = match command { + "pick" | "p" | "reword" | "r" | "edit" | "e" | "squash" | "s" => fields.next(), + "fixup" | "f" => fields.find(|field| !matches!(*field, "-C" | "-c")), + "merge" | "m" => { + let fields = fields.collect::>(); + fields + .windows(2) + .find_map(|pair| matches!(pair[0], "-C" | "-c").then_some(pair[1])) + } + _ => None, + }; + if let Some(oid) = oid { + if oid.len() < 4 || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because {relative} contains an invalid commit object ID" + ), + }); + } + oids.insert(oid.to_owned()); + } + } + Ok(oids) +} + fn run_bounded_command( mut command: Command, args: Vec, @@ -3145,11 +3432,22 @@ where struct RecoveryInputFence { inputs: Vec, probes: Vec, + remaining_rebase_commit_oids: BTreeSet, + spawn_boundary_input: Option, } struct RecoveryProbe { args: Vec, digest: [u8; 32], + status: Option, + safety: RecoveryProbeSafety, +} + +#[derive(Clone, Copy)] +enum RecoveryProbeSafety { + None, + DisabledConfig(&'static str), + ExternalDrivers, } enum RecoveryInput { @@ -3166,11 +3464,31 @@ enum RecoveryInput { } impl RecoveryInputFence { + fn retain_input(&mut self, label: &[u8], input: RecoveryInput) { + if label == b"index" { + self.spawn_boundary_input = Some(self.inputs.len()); + } + self.inputs.push(input); + } + fn validate(&self, git: &Git, deadline: Instant) -> Result<(), GitError> { - git.ensure_recovery_process_configuration_safe_until(deadline)?; for probe in &self.probes { - let output = git.run_bounded_git_digest(probe.args.clone(), deadline)?; - if !output.status.success() { + let output = match probe.safety { + RecoveryProbeSafety::None => { + git.run_bounded_git_digest(probe.args.clone(), deadline)? + } + _ => git.run_bounded_git(probe.args.clone(), deadline, true)?, + }; + ensure_recovery_probe_observation_safe(probe.safety, &probe.args, &output)?; + if output.status.code() != probe.status { + return Err(GitError::Blocked { + message: format!( + "recovery planning is blocked because Git {} status changed while it was fingerprinted", + probe.args.join(" ") + ), + }); + } + if !output.status.success() && output.status.code() != Some(1) { return Err(output.git_error(probe.args.clone())); } if output.stdout.digest != probe.digest { @@ -3182,57 +3500,69 @@ impl RecoveryInputFence { }); } } - for input in &self.inputs { - if Instant::now() >= deadline { - return Err(GitError::TimedOut { - args: vec!["recovery-state".to_owned()], - }); + for (index, input) in self.inputs.iter().enumerate() { + if Some(index) != self.spawn_boundary_input { + Self::validate_input(input, deadline)?; } - match input { - RecoveryInput::Missing(path) => match fs::symlink_metadata(path) { - Err(source) if source.kind() == io::ErrorKind::NotFound => {} - Ok(_) => return Err(recovery_path_changed(path)), - Err(source) => return Err(recovery_state_io(path, source)), - }, - RecoveryInput::Symlink { - path, - target, - metadata, - } => { - let current_target = - fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; - let current_metadata = recovery_symlink_metadata(path)?; - if current_target != *target || !same_recovery_file(metadata, ¤t_metadata) - { - return Err(recovery_path_changed(path)); - } + } + // Git consumes the index immediately on startup, so recheck it after + // every other retained input at the spawn boundary. + if let Some(index) = self.spawn_boundary_input { + Self::validate_input(&self.inputs[index], deadline)?; + } + Ok(()) + } + + fn validate_input(input: &RecoveryInput, deadline: Instant) -> Result<(), GitError> { + if Instant::now() >= deadline { + return Err(GitError::TimedOut { + args: vec!["recovery-state".to_owned()], + }); + } + match input { + RecoveryInput::Missing(path) => match fs::symlink_metadata(path) { + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(recovery_path_changed(path)), + Err(source) => Err(recovery_state_io(path, source)), + }, + RecoveryInput::Symlink { + path, + target, + metadata, + } => { + let current_target = + fs::read_link(path).map_err(|source| recovery_state_io(path, source))?; + let current_metadata = recovery_symlink_metadata(path)?; + if current_target != *target || !same_recovery_file(metadata, ¤t_metadata) { + return Err(recovery_path_changed(path)); } - RecoveryInput::Opened { path, opened } => { - let final_metadata = opened - .file - .as_ref() - .map_or_else(|| fs::symlink_metadata(path), fs::File::metadata) - .map_err(|source| recovery_state_io(path, source))?; - let current = match open_recovery_file(path) { - Ok(current) => current, - Err(RecoveryOpenError::Io(source)) => { - return Err(recovery_state_io(path, source)); - } - Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { - return Err(recovery_path_changed(path)); - } - }; - if !same_recovery_file(&opened.metadata, &final_metadata) - || !opened - .same_path_identity(¤t) - .map_err(|source| recovery_state_io(path, source))? - { + Ok(()) + } + RecoveryInput::Opened { path, opened } => { + let final_metadata = opened + .file + .as_ref() + .map_or_else(|| fs::symlink_metadata(path), fs::File::metadata) + .map_err(|source| recovery_state_io(path, source))?; + let current = match open_recovery_file(path) { + Ok(current) => current, + Err(RecoveryOpenError::Io(source)) => { + return Err(recovery_state_io(path, source)); + } + Err(RecoveryOpenError::Missing | RecoveryOpenError::Symlink) => { return Err(recovery_path_changed(path)); } + }; + if !same_recovery_file(&opened.metadata, &final_metadata) + || !opened + .same_path_identity(¤t) + .map_err(|source| recovery_state_io(path, source))? + { + return Err(recovery_path_changed(path)); } + Ok(()) } } - Ok(()) } } @@ -3263,7 +3593,7 @@ where Err(RecoveryOpenError::Missing) => { hash_field(hasher, b"missing"); if let Some(fence) = context.fence.as_deref_mut() { - fence.inputs.push(RecoveryInput::Missing(path.to_owned())); + fence.retain_input(label, RecoveryInput::Missing(path.to_owned())); } return Ok(()); } @@ -3311,11 +3641,14 @@ where return Err(recovery_path_changed(path)); } if let Some(fence) = context.fence.as_deref_mut() { - fence.inputs.push(RecoveryInput::Symlink { - path: path.to_owned(), - target, - metadata, - }); + fence.retain_input( + label, + RecoveryInput::Symlink { + path: path.to_owned(), + target, + metadata, + }, + ); } return Ok(()); } @@ -3340,6 +3673,8 @@ where )); }; let mut buffer = [0; 64 * 1024]; + let inspect_process_safety = recovery_control_input_requires_safety_inspection(label); + let mut process_safety_contents = Vec::new(); loop { if Instant::now() >= context.deadline { return Err(GitError::TimedOut { @@ -3361,6 +3696,29 @@ where *remaining = updated; } hasher.update(&buffer[..count]); + if inspect_process_safety { + if process_safety_contents.len().saturating_add(count) + > RECOVERY_REBASE_TODO_LIMIT as usize + { + return Err(GitError::Blocked { + message: "recovery is blocked because a process-controlling rebase input exceeds the bounded inspection limit" + .to_owned(), + }); + } + process_safety_contents.extend_from_slice(&buffer[..count]); + } + } + ensure_recovery_control_observation_safe(label, &process_safety_contents)?; + if matches!( + label, + b"rebase-merge/git-rebase-todo" | b"rebase-apply/git-rebase-todo" + ) { + let relative = String::from_utf8_lossy(label); + let contents = String::from_utf8_lossy(&process_safety_contents); + let oids = parse_remaining_rebase_commit_oids(&relative, &contents)?; + if let Some(fence) = context.fence.as_deref_mut() { + fence.remaining_rebase_commit_oids.extend(oids); + } } let final_metadata = file .metadata() @@ -3444,10 +3802,13 @@ where }); } if let Some(fence) = context.fence.as_deref_mut() { - fence.inputs.push(RecoveryInput::Opened { - path: path.to_owned(), - opened, - }); + fence.retain_input( + label, + RecoveryInput::Opened { + path: path.to_owned(), + opened, + }, + ); } Ok(()) } @@ -3761,7 +4122,7 @@ struct RawProcessOutput { stderr: Vec, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct BoundedCommandOutput { status: ExitStatus, stdout: CapturedStream, @@ -3779,7 +4140,7 @@ impl BoundedCommandOutput { } } -#[derive(Debug)] +#[derive(Debug, Clone)] struct CapturedStream { bytes: Vec, digest: [u8; 32], @@ -4471,6 +4832,69 @@ fn ensure_recovery_hook_observation_safe( Ok(()) } +fn recovery_control_input_requires_safety_inspection(label: &[u8]) -> bool { + matches!( + label, + b"rebase-merge/gpg_sign_opt" + | b"rebase-apply/gpg_sign_opt" + | b"rebase-merge/strategy" + | b"rebase-apply/strategy" + | b"rebase-merge/strategy_opts" + | b"rebase-apply/strategy_opts" + | b"rebase-merge/git-rebase-todo" + | b"rebase-apply/git-rebase-todo" + ) +} + +fn ensure_recovery_control_observation_safe(label: &[u8], contents: &[u8]) -> Result<(), GitError> { + if !recovery_control_input_requires_safety_inspection(label) { + return Ok(()); + } + let nonempty = contents.iter().any(|byte| !byte.is_ascii_whitespace()); + if matches!( + label, + b"rebase-merge/gpg_sign_opt" | b"rebase-apply/gpg_sign_opt" + ) && nonempty + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase requests commit signing with --gpg-sign, which may start an uncontained signer; abort and restart the rebase without signing, or run Git manually" + .to_owned(), + }); + } + if matches!(label, b"rebase-merge/strategy" | b"rebase-apply/strategy") && nonempty { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase persists an explicit merge strategy, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" + .to_owned(), + }); + } + if matches!( + label, + b"rebase-merge/strategy_opts" | b"rebase-apply/strategy_opts" + ) && nonempty + { + return Err(GitError::Blocked { + message: "recovery is blocked because the active rebase persists an explicit merge strategy option, which may start an uncontained executable; abort and restart without custom strategy settings, or run Git manually" + .to_owned(), + }); + } + if matches!( + label, + b"rebase-merge/git-rebase-todo" | b"rebase-apply/git-rebase-todo" + ) && contents.split(|byte| *byte == b'\n').any(|line| { + matches!( + line.split(|byte| byte.is_ascii_whitespace()) + .find(|field| !field.is_empty()), + Some(b"exec" | b"x") + ) + }) { + return Err(GitError::Blocked { + message: "recovery is blocked because the remaining rebase plan contains an exec command that may start uncontained processes; remove it and preview recovery again, or run Git manually" + .to_owned(), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -5548,6 +5972,50 @@ mod tests { Ok(()) } + #[test] + fn rebase_continue_blocks_ignored_path_touched_only_by_intermediate_commits() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write(".gitignore", "victim.txt\n")?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", ".gitignore", "conflict.txt"])?; + repo.run(["commit", "-m", "rebase base"])?; + repo.run(["switch", "-c", "topic"])?; + repo.write("conflict.txt", "topic\n")?; + repo.run(["commit", "-am", "conflicting change"])?; + repo.write("victim.txt", "committed intermediate contents\n")?; + repo.run(["add", "-f", "victim.txt"])?; + repo.run(["commit", "-m", "add ignored victim"])?; + fs::remove_file(repo.path().join("victim.txt"))?; + repo.run(["add", "-u"])?; + repo.run(["commit", "-m", "remove ignored victim"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.write("upstream.txt", "upstream\n")?; + repo.run(["add", "conflict.txt", "upstream.txt"])?; + repo.run(["commit", "-m", "upstream change"])?; + repo.run(["switch", "topic"])?; + repo.run_allow_failure(["rebase", "main"])?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + repo.write("victim.txt", "local ignored data\n")?; + let git = Git::new(repo.path()); + + let Err(error) = + git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Continue) + else { + return Err("expected an intermediate rebase collision to block continue".into()); + }; + + assert!(error.to_string().contains("would be overwritten")); + assert_eq!( + fs::read_to_string(repo.path().join("victim.txt"))?, + "local ignored data\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + Ok(()) + } + #[test] fn exact_recovery_rejects_changed_untracked_contents() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; @@ -5643,7 +6111,11 @@ mod tests { return Err("expected a hook enabled during fingerprinting to be rejected".into()); }; - assert!(error.to_string().contains("executable Git hooks")); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } @@ -5679,15 +6151,18 @@ mod tests { return Err("expected swapped executable hook directory to fail closed".into()); }; - assert!(error.to_string().contains("executable Git hooks")); + assert!( + error + .to_string() + .contains("changed while it was fingerprinted") + ); assert!(!marker.exists(), "swapped hook process started"); assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); Ok(()) } #[test] - fn exact_recovery_rejects_index_change_after_it_is_read_in_final_fingerprint() - -> Result<(), Box> { + fn exact_recovery_rechecks_index_after_first_final_fence_pass() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; repo.write("conflict.txt", "resolved\n")?; repo.run(["add", "conflict.txt"])?; @@ -5734,6 +6209,84 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rechecks_process_config_after_first_final_fence_pass() + -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + let output = Command::new("git") + .current_dir(repo.path()) + .args(["config", "core.fsmonitor", "true"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["install synchronized fsmonitor config".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized config mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected final process-config fence to block recovery".into()); + }; + + assert!(error.to_string().contains("core.fsmonitor")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } + + #[test] + fn exact_recovery_rechecks_process_control_after_first_final_fence_pass() + -> Result<(), Box> { + for (relative, contents) in [ + ("rebase-merge/git-rebase-todo", "exec false\n"), + ("rebase-merge/strategy", "resolve\n"), + ] { + let (repo, original_head) = prepare_rebase_conflict()?; + repo.write("conflict.txt", "resolved\n")?; + repo.run(["add", "conflict.txt"])?; + let git = Git::new(repo.path()); + let expected = + git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Continue)?; + let control = git.git_path(relative)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Rebase, + RecoveryAction::Continue, + &expected, + || { + fs::write(&control, contents) + .map_err(|source| recovery_state_io(&control, source)) + }, + ) else { + return Err(format!("expected synchronized {relative} mutation to block").into()); + }; + + assert!(error.to_string().contains("state changed after preview")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase)); + assert_eq!( + repo.git_stdout(["rev-parse", "rebase-merge/orig-head"])? + .trim(), + original_head + ); + } + Ok(()) + } + #[cfg(windows)] #[test] fn recovery_platform_rejects_same_metadata_file_replacement() -> Result<(), Box> { @@ -6132,6 +6685,44 @@ mod tests { Ok(()) } + #[test] + fn recovery_fingerprint_applies_process_policy_to_exact_control_observation() + -> Result<(), Box> { + let repo = TempRepo::new()?; + for (label, contents, expected) in [ + ( + b"rebase-merge/git-rebase-todo".as_slice(), + "exec touch must-not-run\n", + "rebase plan contains an exec", + ), + ( + b"rebase-merge/strategy".as_slice(), + "resolve\n", + "explicit merge strategy", + ), + ] { + let input = repo.path().join("control-input"); + fs::write(&input, contents)?; + let mut hasher = Sha256::new(); + let mut entries = 0; + let mut fence = RecoveryInputFence::default(); + + let Err(error) = hash_recovery_path_fenced( + &mut hasher, + label, + &input, + Instant::now() + Duration::from_secs(2), + &mut entries, + Some(&mut fence), + ) else { + return Err(format!("expected exact {expected} observation to be rejected").into()); + }; + + assert!(error.to_string().contains(expected), "{error}"); + } + Ok(()) + } + #[cfg(unix)] #[test] fn recovery_rejects_persisted_explicit_strategy_before_spawn() -> Result<(), Box> { From f4ffe98fa6c04e79cffaa4d9097fb70e803f204c Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 18:51:52 -0400 Subject: [PATCH 51/53] fix: close recovery review gaps --- crates/bitbygit-git/src/lib.rs | 369 +++++++++++++++++++++++++++------ 1 file changed, 308 insertions(+), 61 deletions(-) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 5f4d8f1..0fcdb45 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -1568,7 +1568,7 @@ impl Git { ]; let output = self.run_bounded_git(args.clone(), deadline, true)?; let safety = RecoveryProbeSafety::ExternalDrivers; - ensure_recovery_probe_observation_safe(safety, &args, &output)?; + self.ensure_recovery_external_drivers_safe_until(&args, &output, deadline)?; hash_recovery_probe(hasher, &output); if let Some(fence) = fence { fence.probes.push(RecoveryProbe { @@ -1638,30 +1638,7 @@ impl Git { "--list".to_owned(), ]; let output = self.run_bounded_git(args.clone(), deadline, true)?; - if !output.status.success() { - return Err(output.git_error(args)); - } - if output.stdout.truncated { - return Err(GitError::Blocked { - message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" - .to_owned(), - }); - } - let names = output - .stdout - .bytes - .split(|byte| *byte == 0) - .filter(|name| is_recovery_external_driver_config(name)) - .map(|name| String::from_utf8_lossy(name).into_owned()) - .collect::>(); - if !names.is_empty() { - return Err(GitError::Blocked { - message: format!( - "recovery is blocked because configured external Git drivers may be activated by a recovery target tree and start uncontained processes: {}; remove them from every Git config scope, preview recovery again, or run Git manually", - names.join(", ") - ), - }); - } + self.ensure_recovery_external_drivers_safe_until(&args, &output, deadline)?; let hooks = self.recovery_hooks_path_until(deadline)?; let enabled_hooks = RECOVERY_HOOKS @@ -1701,6 +1678,160 @@ impl Git { Ok(()) } + fn ensure_recovery_external_drivers_safe_until( + &self, + args: &[String], + output: &BoundedCommandOutput, + deadline: Instant, + ) -> Result<(), GitError> { + if !output.status.success() { + return Err(output.clone().git_error(args.to_vec())); + } + if output.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because Git configuration exceeds the bounded diagnostic limit, so external drivers cannot be ruled out" + .to_owned(), + }); + } + let mut names = Vec::new(); + let mut filter_keys = BTreeMap::>::new(); + for name in output.stdout.bytes.split(|byte| *byte == 0) { + if !is_recovery_external_driver_config(name) { + continue; + } + let display = String::from_utf8_lossy(name).into_owned(); + if let Some(driver) = recovery_filter_driver(name) { + filter_keys.entry(driver).or_default().push(display); + } else { + names.push(display); + } + } + if !filter_keys.is_empty() { + let configured = filter_keys.keys().cloned().collect(); + for driver in self.active_recovery_filters_until(&configured, deadline)? { + if let Some(keys) = filter_keys.get(&driver) { + names.extend(keys.iter().cloned()); + } + } + } + if !names.is_empty() { + return Err(GitError::Blocked { + message: format!( + "recovery is blocked because configured external Git drivers are active or may be activated by a recovery target tree and start uncontained processes: {}; disable the applicable attributes or remove the drivers from every Git config scope, preview recovery again, or run Git manually", + names.join(", ") + ), + }); + } + Ok(()) + } + + fn active_recovery_filters_until( + &self, + configured: &BTreeSet, + deadline: Instant, + ) -> Result, GitError> { + let mut sources = BTreeSet::from([None]); + match self.repository_operation_until(deadline)? { + Some(RepositoryOperation::Merge) => { + sources.insert(Some("ORIG_HEAD".to_owned())); + } + Some(RepositoryOperation::Rebase) => { + sources.insert(self.recovery_overwrite_target_until( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + deadline, + )?); + sources.extend( + self.remaining_rebase_commit_oids_until(deadline)? + .into_iter() + .map(Some), + ); + } + None => {} + } + + let mut active = BTreeSet::new(); + for source in sources { + let list_args = match source.as_deref() { + Some(source) => vec![ + "ls-tree".to_owned(), + "-r".to_owned(), + "--name-only".to_owned(), + "-z".to_owned(), + source.to_owned(), + "--".to_owned(), + ], + None => vec![ + "ls-files".to_owned(), + "--cached".to_owned(), + "-z".to_owned(), + "--".to_owned(), + ], + }; + let paths = self.run_bounded_git(list_args.clone(), deadline, true)?; + if !paths.status.success() { + return Err(paths.git_error(list_args)); + } + if paths.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because paths requiring attribute inspection exceed the bounded output limit" + .to_owned(), + }); + } + let paths = paths + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .collect::>(); + for chunk in paths.chunks(128) { + let mut args = vec!["check-attr".to_owned(), "-z".to_owned()]; + if let Some(source) = source.as_deref() { + args.push(format!("--source={source}")); + } + args.extend(["filter".to_owned(), "--".to_owned()]); + for path in chunk { + let path = std::str::from_utf8(path).map_err(|_| GitError::Blocked { + message: "recovery is blocked because an attribute path is not valid UTF-8" + .to_owned(), + })?; + args.push(path.to_owned()); + } + let attributes = self.run_bounded_git(args.clone(), deadline, true)?; + if !attributes.status.success() { + return Err(attributes.git_error(args)); + } + if attributes.stdout.truncated { + return Err(GitError::Blocked { + message: "recovery is blocked because effective attributes exceed the bounded output limit" + .to_owned(), + }); + } + let fields = attributes + .stdout + .bytes + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .collect::>(); + let mut triples = fields.chunks_exact(3); + for triple in &mut triples { + let driver = String::from_utf8_lossy(triple[2]).to_ascii_lowercase(); + if configured.contains(&driver) { + active.insert(driver); + } + } + if !triples.remainder().is_empty() { + return Err(GitError::Blocked { + message: + "recovery is blocked because effective attributes could not be parsed" + .to_owned(), + }); + } + } + } + Ok(active) + } + fn bounded_config_is_enabled(&self, key: &str, deadline: Instant) -> Result { let args = vec!["config".to_owned(), "--get".to_owned(), key.to_owned()]; let output = self.run_bounded_git(args.clone(), deadline, true)?; @@ -1789,17 +1920,28 @@ impl Git { else { return Ok(()); }; - let changed_args = vec![ - "diff".to_owned(), - "--name-only".to_owned(), - "--no-renames".to_owned(), - "--no-ext-diff".to_owned(), - "--no-textconv".to_owned(), - "-z".to_owned(), - "HEAD".to_owned(), - target, - "--".to_owned(), - ]; + let changed_args = if action == RecoveryAction::Abort { + vec![ + "ls-tree".to_owned(), + "-r".to_owned(), + "--name-only".to_owned(), + "-z".to_owned(), + target, + "--".to_owned(), + ] + } else { + vec![ + "diff".to_owned(), + "--name-only".to_owned(), + "--no-renames".to_owned(), + "--no-ext-diff".to_owned(), + "--no-textconv".to_owned(), + "-z".to_owned(), + "HEAD".to_owned(), + target, + "--".to_owned(), + ] + }; let changed = self.run_bounded_git(changed_args.clone(), deadline, true)?; if !changed.status.success() { return Err(changed.git_error(changed_args)); @@ -2656,6 +2798,15 @@ fn is_recovery_external_driver_config(name: &[u8]) -> bool { || (name.starts_with("merge.") && name.ends_with(".driver")) } +fn recovery_filter_driver(name: &[u8]) -> Option { + let name = String::from_utf8_lossy(name).to_ascii_lowercase(); + let name = name.strip_prefix("filter.")?; + let driver = [".clean", ".smudge", ".process"] + .into_iter() + .find_map(|suffix| name.strip_suffix(suffix))?; + (!driver.is_empty()).then(|| driver.to_owned()) +} + fn hash_recovery_probe(hasher: &mut Sha256, output: &BoundedCommandOutput) { hash_field( hasher, @@ -2715,7 +2866,10 @@ fn ensure_recovery_probe_observation_safe( .stdout .bytes .split(|byte| *byte == 0) - .filter(|name| is_recovery_external_driver_config(name)) + .filter(|name| { + is_recovery_external_driver_config(name) + && recovery_filter_driver(name).is_none() + }) .map(|name| String::from_utf8_lossy(name).into_owned()) .collect::>(); if !names.is_empty() { @@ -2858,7 +3012,7 @@ where break status; } let now = Instant::now(); - if policy != BoundedCommandPolicy::RecoveryExecution && now >= deadline { + if now >= deadline { kill_process_tree(&mut child); let _result = child.wait(); kill_process_tree(&mut child); @@ -2866,11 +3020,7 @@ where join_capture_reader(stderr_reader, &args)?; return Err(GitError::TimedOut { args }); } - let delay = if policy == BoundedCommandPolicy::RecoveryExecution { - Duration::from_millis(5) - } else { - Duration::from_millis(5).min(deadline.saturating_duration_since(now)) - }; + let delay = Duration::from_millis(5).min(deadline.saturating_duration_since(now)); thread::sleep(delay); }; // End the contained process tree before joining the drains so descendants @@ -3518,7 +3668,12 @@ impl RecoveryInputFence { } _ => git.run_bounded_git(probe.args.clone(), deadline, true)?, }; - ensure_recovery_probe_observation_safe(probe.safety, &probe.args, &output)?; + match probe.safety { + RecoveryProbeSafety::ExternalDrivers => { + git.ensure_recovery_external_drivers_safe_until(&probe.args, &output, deadline)? + } + _ => ensure_recovery_probe_observation_safe(probe.safety, &probe.args, &output)?, + } if output.status.code() != probe.status { return Err(GitError::Blocked { message: format!( @@ -5822,7 +5977,7 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_execution_is_not_killed_after_spawn_limits() -> Result<(), Box> { + fn recovery_execution_times_out_after_spawn() -> Result<(), Box> { let mut command = Command::new("sh"); command.args([ "-c", @@ -5830,18 +5985,18 @@ mod tests { ]); configure_process_group(&mut command); - let output = run_bounded_command( + let Err(error) = run_bounded_command( command, vec!["mutating-recovery-test".to_owned()], Instant::now() + Duration::from_millis(20), 32, BoundedCommandPolicy::RecoveryExecution, || Ok(()), - )?; + ) else { + return Err("expected mutating recovery to time out".into()); + }; - assert!(output.status.success()); - assert_eq!(output.stdout.bytes.len(), 32); - assert!(output.stdout.truncated); + assert!(matches!(error, GitError::TimedOut { .. })); Ok(()) } @@ -5887,8 +6042,7 @@ mod tests { #[cfg(windows)] #[test] - fn recovery_platform_execution_is_not_killed_after_spawn_limits() -> Result<(), Box> - { + fn recovery_platform_execution_times_out_after_spawn() -> Result<(), Box> { let mut command = Command::new("powershell"); command.args([ "-NoProfile", @@ -5896,18 +6050,18 @@ mod tests { "[Console]::Out.Write('x' * 5000000); Start-Sleep -Milliseconds 200", ]); - let output = run_bounded_command( + let Err(error) = run_bounded_command( command, vec!["mutating-recovery-test".to_owned()], Instant::now() + Duration::from_millis(100), 32, BoundedCommandPolicy::RecoveryExecution, || Ok(()), - )?; + ) else { + return Err("expected mutating recovery to time out".into()); + }; - assert!(output.status.success()); - assert_eq!(output.stdout.bytes.len(), 32); - assert!(output.stdout.truncated); + assert!(matches!(error, GitError::TimedOut { .. })); Ok(()) } @@ -6011,6 +6165,40 @@ mod tests { Ok(()) } + #[test] + fn merge_abort_blocks_ignored_collision_omitted_from_endpoint_diff() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write("conflict.txt", "base\n")?; + repo.write("stable.txt", "tracked stable contents\n")?; + repo.run(["add", "conflict.txt", "stable.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "other"])?; + repo.write("conflict.txt", "other\n")?; + repo.run(["commit", "-am", "other"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + repo.run_allow_failure(["merge", "other"])?; + repo.run(["rm", "--cached", "stable.txt"])?; + repo.write(".gitignore", "stable.txt\n")?; + repo.write("stable.txt", "local ignored data\n")?; + let git = Git::new(repo.path()); + + let Err(error) = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort) + else { + return Err("expected ignored stable path to block merge abort".into()); + }; + + assert!(error.to_string().contains("would be overwritten")); + assert_eq!( + fs::read_to_string(repo.path().join("stable.txt"))?, + "local ignored data\n" + ); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + Ok(()) + } + #[test] fn rebase_continue_blocks_ignored_path_touched_only_by_intermediate_commits() -> Result<(), Box> { @@ -6288,6 +6476,46 @@ mod tests { Ok(()) } + #[test] + fn exact_recovery_rejects_filter_activated_at_spawn_boundary() -> Result<(), Box> { + let (repo, original_head) = prepare_merge_conflict()?; + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; + let git = Git::new(repo.path()); + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + + let Err(error) = git.recover_exact_with( + RepositoryOperation::Merge, + RecoveryAction::Abort, + &expected, + || { + let output = Command::new("git") + .current_dir(repo.path()) + .args(["config", "filter.unsafe.smudge", "cat"]) + .output() + .map_err(|source| GitError::Io { + args: vec!["install synchronized filter config".to_owned()], + source, + })?; + if !output.status.success() { + return Err(GitError::Blocked { + message: "synchronized filter mutation failed".to_owned(), + }); + } + Ok(()) + }, + ) else { + return Err("expected activated filter at spawn boundary to block recovery".into()); + }; + + assert!(error.to_string().contains("filter.unsafe.smudge")); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + Ok(()) + } + #[test] fn exact_recovery_rechecks_process_control_after_first_final_fence_pass() -> Result<(), Box> { @@ -6490,6 +6718,7 @@ mod tests { #[test] fn recovery_rejects_all_configured_external_driver_kinds() -> Result<(), Box> { let (repo, _original_head) = prepare_merge_conflict()?; + repo.write(".gitattributes", "*.txt filter=unsafe\n")?; let git = Git::new(repo.path()); for key in [ "filter.unsafe.clean", @@ -6528,6 +6757,24 @@ mod tests { Ok(()) } + #[test] + fn recovery_allows_inactive_global_lfs_configuration() -> Result<(), Box> { + let (repo, _original_head) = prepare_merge_conflict()?; + let config_dir = TempRepo::new()?; + let global_config = config_dir.path().join("global-config"); + fs::write( + &global_config, + "[filter \"lfs\"]\n\tclean = git-lfs clean -- %f\n\tsmudge = git-lfs smudge -- %f\n\tprocess = git-lfs filter-process\n\trequired = true\n", + )?; + let git = Git::new(repo.path()).with_test_global_config(global_config); + + let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; + git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; + + assert_eq!(git.status()?.operation, None); + Ok(()) + } + #[test] fn rebase_abort_rejects_filter_selected_only_by_target_tree() -> Result<(), Box> { let repo = initialized_repo()?; @@ -6654,7 +6901,7 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_platform_timeout_kills_descendants() -> Result<(), Box> { + fn recovery_execution_timeout_kills_descendants() -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("descendant-ran"); let mut command = Command::new("sh"); @@ -6670,7 +6917,7 @@ mod tests { vec!["descendant-timeout-test".to_owned()], Instant::now() + Duration::from_millis(100), 4096, - BoundedCommandPolicy::Diagnostic, + BoundedCommandPolicy::RecoveryExecution, || Ok(()), ) else { return Err("expected descendant command to time out".into()); @@ -6683,7 +6930,7 @@ mod tests { #[cfg(windows)] #[test] - fn recovery_platform_timeout_kills_windows_job_descendants() -> Result<(), Box> { + fn recovery_execution_timeout_kills_windows_job_descendants() -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("windows-descendant-ran"); let script = format!( @@ -6698,7 +6945,7 @@ mod tests { vec!["windows-job-timeout-test".to_owned()], Instant::now() + Duration::from_millis(100), 4096, - BoundedCommandPolicy::Diagnostic, + BoundedCommandPolicy::RecoveryExecution, || Ok(()), ) else { return Err("expected Windows descendant command to time out".into()); From 9c0feaa8d4fc30719dd7e42c596e4fcfda419398 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 19:12:31 -0400 Subject: [PATCH 52/53] fix: avoid interrupting mutating recovery --- .github/workflows/ci.yml | 5 +- README.md | 1 + crates/bitbygit-git/src/lib.rs | 254 ++++++++++++++++++----------- docs/installation.md | 2 + scripts/setup-dev.sh | 1 + scripts/test-ci-workflow.sh | 33 ++++ scripts/test-installation-docs.ps1 | 3 + scripts/test-installation-docs.sh | 1 + 8 files changed, 207 insertions(+), 93 deletions(-) create mode 100755 scripts/test-ci-workflow.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0a1644..7b36488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: with: components: clippy, rustfmt - - name: Validate release workflow + - name: Validate workflows shell: bash run: | set -euo pipefail @@ -65,6 +65,7 @@ jobs: echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${tool_dir}/${archive}" | sha256sum -c - tar -C "${tool_dir}" -xzf "${tool_dir}/${archive}" actionlint "${tool_dir}/actionlint" + bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh - name: Validate Linux installation documentation @@ -100,7 +101,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Test recovery support + - name: Test end-to-end recovery support run: cargo test --locked -p bitbygit-git recovery_platform_ - name: Build workspace diff --git a/README.md b/README.md index cd30c05..4731938 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Prerequisites: Run local checks: ```sh +bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh bash scripts/test-installation-docs.sh cargo fmt --all --check diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 0fcdb45..049bc85 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -52,7 +52,7 @@ const COMMIT_HOOKS: &[&str] = &[ "post-commit", ]; const RECOVERY_PLAN_TIMEOUT: Duration = Duration::from_secs(10); -const RECOVERY_EXECUTION_TIMEOUT: Duration = Duration::from_secs(60); +const RECOVERY_START_TIMEOUT: Duration = Duration::from_secs(60); const RECOVERY_OUTPUT_LIMIT: usize = 256 * 1024; const RECOVERY_DIAGNOSTIC_LIMIT: usize = 64 * 1024; const RECOVERY_DIAGNOSTIC_CAPTURE_LIMIT: usize = 64 * 1024 * 1024; @@ -120,7 +120,7 @@ pub struct Git { cwd: PathBuf, ssh_executable: Option, recovery_plan_timeout: Duration, - recovery_execution_timeout: Duration, + recovery_start_timeout: Duration, recovery_output_limit: usize, isolated_test_config: bool, #[cfg(test)] @@ -137,7 +137,7 @@ impl Git { cwd: cwd.into(), ssh_executable: None, recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, - recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, + recovery_start_timeout: RECOVERY_START_TIMEOUT, recovery_output_limit: RECOVERY_OUTPUT_LIMIT, isolated_test_config: cfg!(test), #[cfg(test)] @@ -157,7 +157,7 @@ impl Git { cwd: cwd.into(), ssh_executable: Some(ssh_executable.into()), recovery_plan_timeout: RECOVERY_PLAN_TIMEOUT, - recovery_execution_timeout: RECOVERY_EXECUTION_TIMEOUT, + recovery_start_timeout: RECOVERY_START_TIMEOUT, recovery_output_limit: RECOVERY_OUTPUT_LIMIT, isolated_test_config: cfg!(test), #[cfg(test)] @@ -173,14 +173,14 @@ impl Git { fn with_recovery_limits( cwd: impl Into, plan_timeout: Duration, - execution_timeout: Duration, + start_timeout: Duration, output_limit: usize, ) -> Self { Self { cwd: cwd.into(), ssh_executable: None, recovery_plan_timeout: plan_timeout, - recovery_execution_timeout: execution_timeout, + recovery_start_timeout: start_timeout, recovery_output_limit: output_limit, isolated_test_config: true, test_global_config: None, @@ -384,7 +384,7 @@ impl Git { action: RecoveryAction, ) -> Result { ensure_recovery_execution_supported(operation, action)?; - let deadline = Instant::now() + self.recovery_execution_timeout; + let deadline = Instant::now() + self.recovery_start_timeout; self.ensure_recovery_git_version_until(deadline)?; self.validate_recovery_until(operation, action, deadline)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; @@ -454,7 +454,7 @@ impl Git { F: FnOnce() -> Result<(), GitError>, { ensure_recovery_execution_supported(operation, action)?; - let deadline = Instant::now() + self.recovery_execution_timeout; + let deadline = Instant::now() + self.recovery_start_timeout; self.ensure_recovery_git_version_until(deadline)?; self.validate_recovery_action(operation, action)?; self.ensure_recovery_process_configuration_safe_until(deadline)?; @@ -2996,32 +2996,50 @@ where }); let mut output_limit_hit = false; - let status = loop { - if policy == BoundedCommandPolicy::Diagnostic && capture_exceeded.load(Ordering::Acquire) { - output_limit_hit = true; - kill_process_tree(&mut child); - break child.wait().map_err(|source| GitError::Io { + let status = if policy == BoundedCommandPolicy::RecoveryExecution { + // The deadline protects the checks before spawn. Killing Git after a + // mutating recovery starts can leave a partial worktree or stale lock. + match child.wait() { + Ok(status) => status, + Err(source) => { + kill_process_tree(&mut child); + let _result = child.wait(); + kill_process_tree(&mut child); + let _result = join_capture_reader(stdout_reader, &args); + let _result = join_capture_reader(stderr_reader, &args); + return Err(GitError::Io { args, source }); + } + } + } else { + loop { + if policy == BoundedCommandPolicy::Diagnostic + && capture_exceeded.load(Ordering::Acquire) + { + output_limit_hit = true; + kill_process_tree(&mut child); + break child.wait().map_err(|source| GitError::Io { + args: args.clone(), + source, + })?; + } + if let Some(status) = child.try_wait().map_err(|source| GitError::Io { args: args.clone(), source, - })?; + })? { + break status; + } + let now = Instant::now(); + if now >= deadline { + kill_process_tree(&mut child); + let _result = child.wait(); + kill_process_tree(&mut child); + join_capture_reader(stdout_reader, &args)?; + join_capture_reader(stderr_reader, &args)?; + return Err(GitError::TimedOut { args }); + } + let delay = Duration::from_millis(5).min(deadline.saturating_duration_since(now)); + thread::sleep(delay); } - if let Some(status) = child.try_wait().map_err(|source| GitError::Io { - args: args.clone(), - source, - })? { - break status; - } - let now = Instant::now(); - if now >= deadline { - kill_process_tree(&mut child); - let _result = child.wait(); - kill_process_tree(&mut child); - join_capture_reader(stdout_reader, &args)?; - join_capture_reader(stderr_reader, &args)?; - return Err(GitError::TimedOut { args }); - } - let delay = Duration::from_millis(5).min(deadline.saturating_duration_since(now)); - thread::sleep(delay); }; // End the contained process tree before joining the drains so descendants // cannot keep inherited pipe writers open. @@ -5975,28 +5993,75 @@ mod tests { Ok(()) } - #[cfg(unix)] + #[cfg(any(unix, windows))] #[test] - fn recovery_execution_times_out_after_spawn() -> Result<(), Box> { - let mut command = Command::new("sh"); - command.args([ - "-c", - "sleep 0.1; i=0; while [ $i -lt 5000 ]; do printf x; i=$((i + 1)); done", - ]); + fn recovery_platform_end_to_end_deadline_does_not_interrupt_merge_abort() + -> Result<(), Box> { + let repo = initialized_repo()?; + repo.write("conflict.txt", "base\n")?; + repo.run(["add", "conflict.txt"])?; + repo.run(["commit", "-m", "base"])?; + repo.run(["switch", "-c", "other"])?; + repo.write("conflict.txt", "other\n")?; + for index in 0..2_000 { + repo.write(&format!("generated-{index:04}.txt"), "other\n")?; + } + repo.run(["add", "."])?; + repo.run(["commit", "-m", "other"])?; + repo.run(["switch", "main"])?; + repo.write("conflict.txt", "main\n")?; + repo.run(["commit", "-am", "main"])?; + let original_head = repo.git_stdout(["rev-parse", "HEAD"])?.trim().to_owned(); + repo.run_allow_failure(["merge", "other"])?; + let git = Git::new(repo.path()); + assert_eq!(git.status()?.operation, Some(RepositoryOperation::Merge)); + + let mut command = Command::new("git"); + command + .current_dir(repo.path()) + .args([ + "-c", + "maintenance.auto=false", + "-c", + "gc.auto=0", + "merge", + "--abort", + ]) + .env("GIT_TERMINAL_PROMPT", "0"); configure_process_group(&mut command); + let start_timeout = Duration::from_millis(10); + let started = Instant::now(); - let Err(error) = run_bounded_command( + let output = run_bounded_command( command, - vec!["mutating-recovery-test".to_owned()], - Instant::now() + Duration::from_millis(20), - 32, + vec!["merge".to_owned(), "--abort".to_owned()], + started + start_timeout, + 4096, BoundedCommandPolicy::RecoveryExecution, || Ok(()), - ) else { - return Err("expected mutating recovery to time out".into()); - }; + )?; - assert!(matches!(error, GitError::TimedOut { .. })); + assert!(output.status.success()); + assert!(started.elapsed() >= start_timeout); + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "main\n" + ); + for index in 0..2_000 { + assert!( + !repo + .path() + .join(format!("generated-{index:04}.txt")) + .exists() + ); + } + assert!(!git.git_path("index.lock")?.exists()); + assert!(repo.git_stdout(["status", "--porcelain"])?.is_empty()); Ok(()) } @@ -6040,31 +6105,6 @@ mod tests { Ok(()) } - #[cfg(windows)] - #[test] - fn recovery_platform_execution_times_out_after_spawn() -> Result<(), Box> { - let mut command = Command::new("powershell"); - command.args([ - "-NoProfile", - "-Command", - "[Console]::Out.Write('x' * 5000000); Start-Sleep -Milliseconds 200", - ]); - - let Err(error) = run_bounded_command( - command, - vec!["mutating-recovery-test".to_owned()], - Instant::now() + Duration::from_millis(100), - 32, - BoundedCommandPolicy::RecoveryExecution, - || Ok(()), - ) else { - return Err("expected mutating recovery to time out".into()); - }; - - assert!(matches!(error, GitError::TimedOut { .. })); - Ok(()) - } - #[test] fn exact_recovery_rejects_changed_merge_control_input() -> Result<(), Box> { let (repo, original_head) = prepare_merge_conflict()?; @@ -6901,28 +6941,27 @@ mod tests { #[cfg(unix)] #[test] - fn recovery_execution_timeout_kills_descendants() -> Result<(), Box> { + fn recovery_platform_execution_cleans_descendants_after_parent_exit() + -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("descendant-ran"); let mut command = Command::new("sh"); command .arg("-c") - .arg("(sleep 1; touch \"$1\") & wait") + .arg("(sleep 1; touch \"$1\") &") .arg("sh") .arg(&marker); configure_process_group(&mut command); - let Err(error) = run_bounded_command( + let output = run_bounded_command( command, - vec!["descendant-timeout-test".to_owned()], - Instant::now() + Duration::from_millis(100), + vec!["descendant-cleanup-test".to_owned()], + Instant::now() + Duration::from_secs(5), 4096, BoundedCommandPolicy::RecoveryExecution, || Ok(()), - ) else { - return Err("expected descendant command to time out".into()); - }; - assert!(matches!(error, GitError::TimedOut { .. })); + )?; + assert!(output.status.success()); thread::sleep(Duration::from_millis(1100)); assert!(!marker.exists()); Ok(()) @@ -6930,27 +6969,26 @@ mod tests { #[cfg(windows)] #[test] - fn recovery_execution_timeout_kills_windows_job_descendants() -> Result<(), Box> { + fn recovery_platform_execution_cleans_windows_job_descendants_after_parent_exit() + -> Result<(), Box> { let repo = TempRepo::new()?; let marker = repo.path().join("windows-descendant-ran"); let script = format!( - "$child = Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 1; Set-Content -Path ''{}'' -Value ran' -PassThru; Wait-Process -Id $child.Id", + "Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 1; Set-Content -Path ''{}'' -Value ran' | Out-Null", marker.display() ); let mut command = Command::new("powershell"); command.args(["-NoProfile", "-Command", &script]); - let Err(error) = run_bounded_command( + let output = run_bounded_command( command, - vec!["windows-job-timeout-test".to_owned()], - Instant::now() + Duration::from_millis(100), + vec!["windows-job-cleanup-test".to_owned()], + Instant::now() + Duration::from_secs(5), 4096, BoundedCommandPolicy::RecoveryExecution, || Ok(()), - ) else { - return Err("expected Windows descendant command to time out".into()); - }; - assert!(matches!(error, GitError::TimedOut { .. })); + )?; + assert!(output.status.success()); thread::sleep(Duration::from_millis(1200)); assert!(!marker.exists()); Ok(()) @@ -7311,14 +7349,48 @@ mod tests { #[cfg(any(unix, windows))] #[test] - fn recovery_platform_capability_is_available() -> Result<(), Box> { + fn recovery_platform_end_to_end_exact_merge_and_rebase() -> Result<(), Box> { ensure_recovery_execution_supported(RepositoryOperation::Merge, RecoveryAction::Abort)?; - let (repo, _original_head) = prepare_merge_conflict()?; + let (repo, original_head) = prepare_merge_conflict()?; let storage = TempRepo::new()?; let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); let expected = git.prepare_recovery(RepositoryOperation::Merge, RecoveryAction::Abort)?; git.recover_exact(RepositoryOperation::Merge, RecoveryAction::Abort, &expected)?; assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert!(!git.git_path("index.lock")?.exists()); + drop(git.acquire_recovery_lock_until( + RepositoryOperation::Merge, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?); + + let (repo, original_head) = prepare_rebase_conflict()?; + let git = Git::new(repo.path()).with_recovery_data_dir(storage.path()); + let expected = git.prepare_recovery(RepositoryOperation::Rebase, RecoveryAction::Abort)?; + git.recover_exact( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + &expected, + )?; + assert_eq!(git.status()?.operation, None); + assert_eq!( + repo.git_stdout(["rev-parse", "HEAD"])?.trim(), + original_head + ); + assert_eq!( + fs::read_to_string(repo.path().join("conflict.txt"))?, + "topic\n" + ); + assert!(!git.git_path("index.lock")?.exists()); + drop(git.acquire_recovery_lock_until( + RepositoryOperation::Rebase, + RecoveryAction::Abort, + Instant::now() + Duration::from_secs(2), + )?); Ok(()) } diff --git a/docs/installation.md b/docs/installation.md index 72b5315..ba87a01 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,6 +8,8 @@ instructions once the version you want appears on the ## Runtime prerequisites - `git` must be installed and available on `PATH`. + Guarded conflict recovery requires Git 2.42 or newer; other operations remain + available with older Git versions. - [GitHub CLI (`gh`)](https://cli.github.com/) is optional. It is required only for GitHub-specific operations such as opening a pull request; run `gh auth login` before using those operations. diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 8697e52..a477add 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -1,6 +1,7 @@ #!/usr/bin/env sh set -eu +bash scripts/test-ci-workflow.sh bash scripts/test-release-workflow.sh bash scripts/test-installation-docs.sh cargo fmt --all --check diff --git a/scripts/test-ci-workflow.sh b/scripts/test-ci-workflow.sh new file mode 100755 index 0000000..362b276 --- /dev/null +++ b/scripts/test-ci-workflow.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflow="${root}/.github/workflows/ci.yml" +test_source="$(<"${root}/crates/bitbygit-git/src/lib.rs")" +recovery_job="$(perl -0777 -ne ' + print $1 if /\n recovery-platforms:\n(.*?)(?=\n [a-z][a-z0-9-]*:\n|\z)/s +' "${workflow}")" + +expected='cargo test --locked -p bitbygit-git recovery_platform_' +if [[ -z "${recovery_job}" || "${recovery_job}" != *"${expected}"* ]]; then + printf 'CI workflow does not run end-to-end recovery on supported platforms\n' >&2 + exit 1 +fi +if [[ "${recovery_job}" != *'macos-latest'* || "${recovery_job}" != *'windows-latest'* ]]; then + printf 'CI workflow does not cover macOS and Windows recovery\n' >&2 + exit 1 +fi +for test_name in \ + recovery_platform_command_output_is_bounded \ + recovery_platform_end_to_end_deadline_does_not_interrupt_merge_abort \ + recovery_platform_end_to_end_exact_merge_and_rebase \ + recovery_platform_execution_cleans_descendants_after_parent_exit \ + recovery_platform_execution_cleans_windows_job_descendants_after_parent_exit; do + if [[ "${test_source}" != *"fn ${test_name}"* ]]; then + printf 'CI recovery selector is missing test %s\n' "${test_name}" >&2 + exit 1 + fi +done + +printf 'CI workflow validation passed\n' diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 7f576b0..ec3f711 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -56,6 +56,9 @@ if ($SelectedVersion -eq $WorkspaceVersion -or $SelectedVersion -eq "0.1.0") { if (-not $DocsText.Contains("`$Version = `"$WorkspaceVersion`"")) { Fail "PowerShell examples do not use workspace version $WorkspaceVersion" } +if (-not $DocsText.Contains("Guarded conflict recovery requires Git 2.42 or newer")) { + Fail "runtime prerequisites do not document the guarded recovery Git version" +} $UserPathOrder = '$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";"' if ([regex]::Matches($DocsText, [regex]::Escape($UserPathOrder)).Count -ne 2) { Fail "Windows examples do not prioritize the install directory in user PATH" diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index ab47f2d..268e0bf 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -339,6 +339,7 @@ extract_selected_block "${failure_heading}" bash > "${cleanup_failure_dir}/snipp [[ "${docs_text}" == *'git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" [[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" [[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" +[[ "${docs_text}" == *'Guarded conflict recovery requires Git 2.42 or newer'* ]] || fail "runtime prerequisites do not document the guarded recovery Git version" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" [[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" [[ "$(grep -c 'resolved_binary="$(type -P bitbygit || true)"' "${docs}")" -eq 3 ]] || fail "Unix installations do not bypass aliases and functions during PATH resolution" From f47a1bcc5278da4dfbec920c18ba629d66377c98 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Fri, 17 Jul 2026 19:15:39 -0400 Subject: [PATCH 53/53] test: stabilize recovery fixture line endings --- crates/bitbygit-git/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 049bc85..b50b8a5 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -5998,6 +5998,7 @@ mod tests { fn recovery_platform_end_to_end_deadline_does_not_interrupt_merge_abort() -> Result<(), Box> { let repo = initialized_repo()?; + repo.run(["config", "core.autocrlf", "false"])?; repo.write("conflict.txt", "base\n")?; repo.run(["add", "conflict.txt"])?; repo.run(["commit", "-m", "base"])?;