diff --git a/src/bin/git-remote-e2ee.rs b/src/bin/git-remote-e2ee.rs index ba61851..d37a01f 100644 --- a/src/bin/git-remote-e2ee.rs +++ b/src/bin/git-remote-e2ee.rs @@ -1,10 +1,10 @@ use std::io::{self, BufRead, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::PathBuf; use anyhow::{Context, Result, bail}; use git_remote_e2ee::continuity::default_state_dir; use git_remote_e2ee::crypto::KeyFile; +use git_remote_e2ee::gitcmd::GitRepository; use git_remote_e2ee::repository::{EncryptedRepository, PushRejection, ReadOnlyDevice, RefChange}; use git_remote_e2ee::storage::{CasConflict, FilesystemStorage, GitStorage, Storage}; use git_remote_e2ee::url::StorageUrl; @@ -24,25 +24,32 @@ fn run() -> Result<()> { bail!("unexpected extra argument") } - let key_path = configured_key_path(&remote_name)?; + let local = GitRepository::from_environment()? + .context("the e2ee remote helper must run inside a Git repository")?; + let key_path = configured_key_path(&local, &remote_name)?; let key = KeyFile::read(&key_path)?; let storage = StorageUrl::parse(&url)?; - persist_absolute_url(&remote_name, &url, &storage)?; + persist_absolute_url(&local, &remote_name, &url, &storage)?; let state_dir = default_state_dir()?; match storage { StorageUrl::Directory(path) => run_protocol( EncryptedRepository::new(FilesystemStorage::new(path), key, state_dir), + &local, remote_name, ), StorageUrl::Git(remote) => run_protocol( EncryptedRepository::new(GitStorage::open(&remote)?, key, state_dir), + &local, remote_name, ), } } -fn run_protocol(repository: EncryptedRepository, remote_name: String) -> Result<()> { - let local_repository = Path::new("."); +fn run_protocol( + repository: EncryptedRepository, + local_repository: &GitRepository, + remote_name: String, +) -> Result<()> { let stdin = io::stdin(); let mut lines = stdin.lock().lines(); let stdout = io::stdout(); @@ -120,7 +127,7 @@ fn run_protocol(repository: EncryptedRepository, remote_name: Str fn process_push_batch( repository: &EncryptedRepository, - local_repository: &Path, + local_repository: &GitRepository, remote_name: &str, first: &str, lines: &mut impl Iterator>, @@ -215,15 +222,16 @@ fn consume_batch( Ok(()) } -fn configured_key_path(remote_name: &str) -> Result { +fn configured_key_path(local: &GitRepository, remote_name: &str) -> Result { let key = format!("remote.{remote_name}.e2ee-key"); - if let Some(path) = configured_path(&key)? { + if let Some(path) = configured_path(local, &key)? { return Ok(path); } - if let Some(path) = configured_path("e2ee.key")? { + if let Some(path) = configured_path(local, "e2ee.key")? { let path = std::path::absolute(&path) .with_context(|| format!("resolve key path {}", path.display()))?; - set_local_config(&key, &path.to_string_lossy()).context("persist clone-time key config")?; + set_local_config(local, &key, &path.to_string_lossy()) + .context("persist clone-time key config")?; return Ok(path); } bail!("missing Git config {key} (or clone-time e2ee.key)") @@ -232,21 +240,27 @@ fn configured_key_path(remote_name: &str) -> Result { /// A relative storage path in a clone URL is resolved against the directory /// `git clone` ran in, which later fetches from inside the clone do not share. /// Rewrite the remote's URL to the absolute path the first time it is seen. -fn persist_absolute_url(remote_name: &str, url: &str, storage: &StorageUrl) -> Result<()> { +fn persist_absolute_url( + local: &GitRepository, + remote_name: &str, + url: &str, + storage: &StorageUrl, +) -> Result<()> { let absolute = storage.to_string(); let key = format!("remote.{remote_name}.url"); - let Some(configured) = configured_value(&key)? else { + let Some(configured) = configured_value(local, &key)? else { return Ok(()); }; let as_given = format!("e2ee::{}", url.strip_prefix("e2ee::").unwrap_or(url)); if configured != as_given || configured == absolute { return Ok(()); } - set_local_config(&key, &absolute).context("persist absolute remote URL") + set_local_config(local, &key, &absolute).context("persist absolute remote URL") } -fn set_local_config(key: &str, value: &str) -> Result<()> { - let output = Command::new("git") +fn set_local_config(local: &GitRepository, key: &str, value: &str) -> Result<()> { + let output = local + .git() .args(["config", "--local", key, value]) .output()?; if !output.status.success() { @@ -258,10 +272,8 @@ fn set_local_config(key: &str, value: &str) -> Result<()> { Ok(()) } -fn configured_value(key: &str) -> Result> { - let output = Command::new("git") - .args(["config", "--get", key]) - .output()?; +fn configured_value(local: &GitRepository, key: &str) -> Result> { + let output = local.git().args(["config", "--get", key]).output()?; if output.status.success() { Ok(Some(String::from_utf8(output.stdout)?.trim().to_owned())) } else { @@ -269,8 +281,9 @@ fn configured_value(key: &str) -> Result> { } } -fn configured_path(key: &str) -> Result> { - let output = Command::new("git") +fn configured_path(local: &GitRepository, key: &str) -> Result> { + let output = local + .git() .args(["config", "--path", "--get", key]) .output()?; if output.status.success() { diff --git a/src/git.rs b/src/git.rs index 336f459..436adff 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,29 +1,16 @@ use std::collections::{BTreeMap, HashSet}; use std::fs::File; use std::io::{Read, Write}; -use std::path::Path; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::process::{Child, ChildStdin, ChildStdout, Stdio}; use anyhow::{Context, Result, bail}; +use crate::gitcmd::GitRepository; use crate::trace; -pub fn ensure_repository(repo: &Path) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--git-dir"]) - .output()?; - if !output.status.success() { - bail!("{} is not a Git repository", repo.display()) - } - Ok(()) -} - -pub fn has_complete_object_graph(repo: &Path) -> bool { - let shallow = match Command::new("git") - .arg("-C") - .arg(repo) +pub fn has_complete_object_graph(repo: &GitRepository) -> bool { + let shallow = match repo + .git() .args(["rev-parse", "--is-shallow-repository"]) .output() { @@ -34,9 +21,8 @@ pub fn has_complete_object_graph(repo: &Path) -> bool { return false; } - let partial = match Command::new("git") - .arg("-C") - .arg(repo) + let partial = match repo + .git() .args([ "config", "--local", @@ -52,7 +38,7 @@ pub fn has_complete_object_graph(repo: &Path) -> bool { partial.status.code() == Some(1) && partial.stdout.is_empty() } -pub fn resolve_ref(repo: &Path, reference: &str) -> Result { +pub fn resolve_ref(repo: &GitRepository, reference: &str) -> Result { git_text(repo, &["rev-parse", "--verify", reference]) } @@ -62,7 +48,7 @@ pub enum InnerRefKind { Tag, } -pub fn validate_inner_ref(repo: &Path, reference: &str) -> Result { +pub fn validate_inner_ref(repo: &GitRepository, reference: &str) -> Result { let kind = if reference.starts_with("refs/heads/") { InnerRefKind::Branch } else if reference.starts_with("refs/tags/") { @@ -72,18 +58,14 @@ pub fn validate_inner_ref(repo: &Path, reference: &str) -> Result "unsupported ref namespace for {reference}; only refs/heads/* and refs/tags/* are supported" ) }; - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["check-ref-format", reference]) - .output()?; + let output = repo.git().args(["check-ref-format", reference]).output()?; if !output.status.success() { bail!("invalid Git ref name {reference}") } Ok(kind) } -pub fn ensure_branch_target(repo: &Path, reference: &str, object: &str) -> Result<()> { +pub fn ensure_branch_target(repo: &GitRepository, reference: &str, object: &str) -> Result<()> { let target = resolve_tag_chain(repo, reference, object, "pushed")?; if target.kind != "commit" { bail!("branch ref {reference} must resolve to a commit") @@ -91,10 +73,9 @@ pub fn ensure_branch_target(repo: &Path, reference: &str, object: &str) -> Resul Ok(()) } -pub fn is_ancestor(repo: &Path, ancestor: &str, descendant: &str) -> Result { - let status = Command::new("git") - .arg("-C") - .arg(repo) +pub fn is_ancestor(repo: &GitRepository, ancestor: &str, descendant: &str) -> Result { + let status = repo + .git() .args(["merge-base", "--is-ancestor", ancestor, descendant]) .status()?; match status.code() { @@ -104,12 +85,8 @@ pub fn is_ancestor(repo: &Path, ancestor: &str, descendant: &str) -> Result Result { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(["cat-file", "-e", object]) - .status()?; +pub fn object_exists(repo: &GitRepository, object: &str) -> Result { + let status = repo.git().args(["cat-file", "-e", object]).status()?; match status.code() { Some(0) => Ok(true), Some(1) => Ok(false), @@ -159,13 +136,12 @@ impl Drop for PackSource { } pub fn start_incremental_pack( - repo: &Path, + repo: &GitRepository, new_refs: &BTreeMap, old_refs: &BTreeMap, ) -> Result { - let shallow = Command::new("git") - .arg("-C") - .arg(repo) + let shallow = repo + .git() .args(["rev-parse", "--is-shallow-repository"]) .output()?; if !shallow.status.success() { @@ -176,9 +152,8 @@ pub fn start_incremental_pack( } let timer = trace::Span::new("git_pack_objects_stream_lifetime"); let stderr = tempfile::tempfile().context("create pack-objects stderr file")?; - let mut child = Command::new("git") - .arg("-C") - .arg(repo) + let mut child = repo + .git() .args(["pack-objects", "--stdout", "--revs"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -259,12 +234,11 @@ impl Drop for PackImporter { } } -pub fn start_pack_import(repo: &Path) -> Result { +pub fn start_pack_import(repo: &GitRepository) -> Result { let timer = trace::Span::new("git_index_pack"); let stderr = tempfile::tempfile().context("create index-pack stderr file")?; - let mut child = Command::new("git") - .arg("-C") - .arg(repo) + let mut child = repo + .git() .args(["index-pack", "--stdin"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) @@ -290,7 +264,7 @@ fn read_process_error(file: &mut File) -> String { } pub fn ensure_refs_connected_since( - repo: &Path, + repo: &GitRepository, refs: &BTreeMap, verified_refs: &BTreeMap, ) -> Result<()> { @@ -334,11 +308,8 @@ pub fn ensure_refs_connected_since( if commit_roots.is_empty() { return Ok(()); } - let mut command = Command::new("git"); - command - .arg("-C") - .arg(repo) - .args(["rev-list", "--objects", "--missing=print"]); + let mut command = repo.git(); + command.args(["rev-list", "--objects", "--missing=print"]); for object in &commit_roots { command.arg(object); } @@ -372,7 +343,12 @@ struct RefTarget { kind: String, } -fn resolve_tag_chain(repo: &Path, reference: &str, object: &str, label: &str) -> Result { +fn resolve_tag_chain( + repo: &GitRepository, + reference: &str, + object: &str, + label: &str, +) -> Result { let mut current = object.to_owned(); let mut seen = HashSet::new(); loop { @@ -416,14 +392,13 @@ fn resolve_tag_chain(repo: &Path, reference: &str, object: &str, label: &str) -> } } -fn cat_file_type(repo: &Path, object: &str) -> Result { +fn cat_file_type(repo: &GitRepository, object: &str) -> Result { git_text(repo, &["cat-file", "-t", object]) } -fn ensure_tree_connected(repo: &Path, reference: &str, tree: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo) +fn ensure_tree_connected(repo: &GitRepository, reference: &str, tree: &str) -> Result<()> { + let output = repo + .git() .args(["ls-tree", "-r", "-t", "-z", "--full-tree", tree]) .output()?; if !output.status.success() { @@ -461,10 +436,9 @@ fn ensure_tree_connected(repo: &Path, reference: &str, tree: &str) -> Result<()> Ok(()) } -pub fn update_ref(repo: &Path, reference: &str, object: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo) +pub fn update_ref(repo: &GitRepository, reference: &str, object: &str) -> Result<()> { + let output = repo + .git() .args(["update-ref", reference, object]) .output()?; if !output.status.success() { @@ -476,12 +450,8 @@ pub fn update_ref(repo: &Path, reference: &str, object: &str) -> Result<()> { Ok(()) } -fn git_text(repo: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output()?; +fn git_text(repo: &GitRepository, args: &[&str]) -> Result { + let output = repo.git().args(args).output()?; if !output.status.success() { bail!( "git {} failed: {}", @@ -495,6 +465,7 @@ fn git_text(repo: &Path, args: &[&str]) -> Result { #[cfg(test)] mod tests { use super::has_complete_object_graph; + use crate::gitcmd::GitRepository; use std::fs; use std::path::Path; use std::process::Command; @@ -532,7 +503,9 @@ mod tests { git(&full, &["add", "file"]); git(&full, &["commit", "--quiet", "-m", contents]); } - assert!(has_complete_object_graph(&full)); + assert!(has_complete_object_graph( + &GitRepository::open(&full).unwrap() + )); let shallow = temporary.path().join("shallow"); let source_url = format!("file://{}", full.display()); @@ -547,12 +520,18 @@ mod tests { "git clone: {}", String::from_utf8_lossy(&clone.stderr) ); - assert!(!has_complete_object_graph(&shallow)); + assert!(!has_complete_object_graph( + &GitRepository::open(&shallow).unwrap() + )); git(&full, &["config", "remote.origin.promisor", "true"]); - assert!(!has_complete_object_graph(&full)); + assert!(!has_complete_object_graph( + &GitRepository::open(&full).unwrap() + )); git(&full, &["config", "--unset", "remote.origin.promisor"]); git(&full, &["config", "extensions.partialClone", "origin"]); - assert!(!has_complete_object_graph(&full)); + assert!(!has_complete_object_graph( + &GitRepository::open(&full).unwrap() + )); } } diff --git a/src/gitcmd.rs b/src/gitcmd.rs new file mode 100644 index 0000000..38df395 --- /dev/null +++ b/src/gitcmd.rs @@ -0,0 +1,215 @@ +//! The single place that spawns `git`. Every command names its repository +//! explicitly and runs under one of two environment policies: +//! +//! - The user's repository: addressed by its absolute Git directory, with the +//! inherited repository-selecting variables removed so they cannot redirect +//! the command elsewhere. The user's configuration applies. +//! - A repository this program owns (carrier cache, carrier checkout, +//! recovery's scratch repository): repository selection and injected +//! command-line configuration are removed, and incidental behaviour such as +//! hooks and signing is disabled. Global and system configuration still +//! apply, because carrier transport needs credentials, ssh, proxies, and +//! `url.*.insteadOf`. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Result, bail}; + +/// Variables through which an environment selects a repository, its object +/// store, its history, or extra configuration. +const REPOSITORY_SELECTION: &[&str] = &[ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_QUARANTINE_PATH", + "GIT_NAMESPACE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_GRAFT_FILE", + "GIT_REPLACE_REF_BASE", + "GIT_NO_REPLACE_OBJECTS", +]; + +/// Configuration injected by `git -c` or `GIT_CONFIG_COUNT` in the caller. +/// It targets the user's repository, never one this program owns. +const INJECTED_CONFIG: &[&str] = &["GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_CONFIG"]; + +/// Identity and dates a caller may export for its own commits. +const COMMIT_IDENTITY: &[&str] = &[ + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_DATE", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_DATE", +]; + +/// Settings that keep commands on owned repositories free of user hooks, +/// signing, and prompts. `/dev/null` is not a directory, so no hook exists. +const OWNED_REPOSITORY_CONFIG: &[&str] = &[ + "core.hooksPath=/dev/null", + "commit.gpgSign=false", + "tag.gpgSign=false", + "push.gpgSign=false", + "core.fsmonitor=false", +]; + +/// A Git repository every command names explicitly, with the environment +/// policy of whoever owns it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitRepository { + location: Location, + owner: Owner, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Location { + /// An absolute Git directory, used without a work tree. + GitDir(PathBuf), + /// A non-bare repository's work tree; commands run inside it. + WorkTree(PathBuf), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Owner { + User, + Program, +} + +impl GitRepository { + /// The user's repository that Git selected for this process: `GIT_DIR` + /// when Git exported it (as it does for remote helpers and + /// `git `), otherwise discovery from the working directory. + /// `None` outside any repository. + pub fn from_environment() -> Result> { + let output = Command::new("git") + .args(["rev-parse", "--absolute-git-dir"]) + .output()?; + Self::parse_discovery(output) + } + + /// The user's repository at `path`, ignoring any repository the + /// environment selects. + pub fn open(path: &Path) -> Result { + let mut command = Command::new("git"); + remove_all(&mut command, REPOSITORY_SELECTION); + let output = command + .arg("-C") + .arg(path) + .args(["rev-parse", "--absolute-git-dir"]) + .output()?; + match Self::parse_discovery(output)? { + Some(repository) => Ok(repository), + None => bail!("{} is not a Git repository", path.display()), + } + } + + /// What `--repo` means for every command: the named repository, or the + /// one the environment selects. + pub fn resolve(path: Option<&Path>) -> Result> { + match path { + Some(path) => Self::open(path).map(Some), + None => Self::from_environment(), + } + } + + fn parse_discovery(output: std::process::Output) -> Result> { + if !output.status.success() { + return Ok(None); + } + let git_dir = PathBuf::from(String::from_utf8(output.stdout)?.trim_end_matches('\n')); + if !git_dir.is_absolute() { + bail!("git rev-parse returned a relative Git directory") + } + Ok(Some(Self { + location: Location::GitDir(git_dir), + owner: Owner::User, + })) + } + + /// A bare repository or Git directory this program created. + pub(crate) fn owned_git_dir(path: &Path) -> Self { + Self { + location: Location::GitDir(path.to_path_buf()), + owner: Owner::Program, + } + } + + /// A non-bare repository this program created, addressed by its work tree. + pub(crate) fn owned_work_tree(path: &Path) -> Self { + Self { + location: Location::WorkTree(path.to_path_buf()), + owner: Owner::Program, + } + } + + /// A command on this owned repository that talks to the carrier remote. + /// Unlike `git()`, it keeps configuration the caller injected with + /// `git -c` (proxies, credential helpers, `insteadOf` rewrites), which + /// transport needs; the owned-repository overrides are appended after it, + /// so they still win for hooks and signing. + pub(crate) fn transport(&self) -> Command { + debug_assert_eq!(self.owner, Owner::Program); + let mut command = Command::new("git"); + remove_all(&mut command, REPOSITORY_SELECTION); + remove_all(&mut command, COMMIT_IDENTITY); + for setting in OWNED_REPOSITORY_CONFIG { + command.arg("-c").arg(setting); + } + match &self.location { + Location::GitDir(path) => command.arg("--git-dir").arg(path), + Location::WorkTree(path) => command.arg("-C").arg(path), + }; + command + } + + /// The user's Git directory, where per-repository state lives. + pub fn git_dir(&self) -> PathBuf { + match &self.location { + Location::GitDir(path) => path.clone(), + Location::WorkTree(path) => path.join(".git"), + } + } + + /// A `git` command bound to this repository. + pub fn git(&self) -> Command { + let mut command = match self.owner { + Owner::User => { + let mut command = Command::new("git"); + remove_all(&mut command, REPOSITORY_SELECTION); + command + } + Owner::Program => without_repository(), + }; + match &self.location { + Location::GitDir(path) => command.arg("--git-dir").arg(path), + Location::WorkTree(path) => command.arg("-C").arg(path), + }; + command + } +} + +/// A `git` command that touches no repository (`init`, `check-ref-format`), +/// under the policy for repositories this program owns. +pub(crate) fn without_repository() -> Command { + let mut command = Command::new("git"); + remove_all(&mut command, REPOSITORY_SELECTION); + remove_all(&mut command, INJECTED_CONFIG); + remove_all(&mut command, COMMIT_IDENTITY); + for setting in OWNED_REPOSITORY_CONFIG { + command.arg("-c").arg(setting); + } + command +} + +fn remove_all(command: &mut Command, variables: &[&str]) { + for variable in variables { + command.env_remove(variable); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4e013c7..a0e3cf8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod continuity; pub mod crypto; pub mod error; pub mod git; +pub mod gitcmd; pub mod manifest; mod persist; pub mod policy; diff --git a/src/main.rs b/src/main.rs index 44b8b4c..206dd16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use git_remote_e2ee::continuity::default_state_dir; use git_remote_e2ee::crypto::{KeyFile, PublicDevice}; +use git_remote_e2ee::gitcmd::GitRepository; use git_remote_e2ee::policy::{DeviceRoles, validate_device_label}; use git_remote_e2ee::repository::{ EncryptedRepository, InitializeError, RecoveryOptions, RecoveryReport, @@ -106,8 +107,10 @@ enum Command { target: String, #[arg(long)] key: Option, - #[arg(long, default_value = ".")] - repo: PathBuf, + /// Repository whose remotes, key, and recovery state apply. Defaults + /// to the repository Git selects for the current directory + #[arg(long)] + repo: Option, #[arg(long)] publish: bool, #[arg(long, requires = "publish")] @@ -125,22 +128,29 @@ struct Target { } impl Target { - fn resolve(target: &str, key: Option) -> Result { - let configured = if target.starts_with("e2ee::") { - None - } else { - git_config(&format!("remote.{target}.url"), false)? + /// Resolves a remote name through `repository`'s configuration, or takes + /// `target` as a storage URL. + fn resolve( + target: &str, + key: Option, + repository: Option<&GitRepository>, + ) -> Result { + let configured = match repository { + Some(repository) if !target.starts_with("e2ee::") => { + git_config(repository, &format!("remote.{target}.url"), false)? + } + _ => None, }; - let (url, configured_key) = match configured { - Some(url) if url.starts_with("e2ee::") => { - let key = git_config(&format!("remote.{target}.e2ee-key"), true)?; + let (url, configured_key) = match (configured, repository) { + (Some(url), Some(repository)) if url.starts_with("e2ee::") => { + let key = git_config(repository, &format!("remote.{target}.e2ee-key"), true)?; (url, key.map(PathBuf::from)) } - Some(url) => bail!("Git remote {target} is not an e2ee:: remote ({url})"), - None if target.starts_with("e2ee::") || target.contains(['/', ':']) => { + (Some(url), _) => bail!("Git remote {target} is not an e2ee:: remote ({url})"), + (None, _) if target.starts_with("e2ee::") || target.contains(['/', ':']) => { (target.to_owned(), None) } - None => bail!( + (None, _) => bail!( "no Git remote named {target}; give a storage URL such as e2ee::https://host/you/repo.git" ), }; @@ -219,8 +229,10 @@ fn main() -> Result<()> { None => default_owner_label(), }; validate_device_label(&label)?; - let in_repository = inside_git_repository()?; - if in_repository && git_config(&format!("remote.{name}.url"), false)?.is_some() { + let local = GitRepository::from_environment()?; + if let Some(local) = &local + && git_config(local, &format!("remote.{name}.url"), false)?.is_some() + { bail!("Git remote {name} already exists; choose another name with --name") } let mut created_key = None; @@ -263,18 +275,25 @@ fn main() -> Result<()> { } }; println!("initialized {}", target.storage); - if in_repository { - run_git(&["remote", "add", &name, &target.storage.to_string()])?; - run_git(&[ - "config", - &format!("remote.{name}.e2ee-key"), - &target.key.to_string_lossy(), - ])?; + if let Some(local) = &local { + run_git( + local, + &["remote", "add", &name, &target.storage.to_string()], + )?; + run_git( + local, + &[ + "config", + &format!("remote.{name}.e2ee-key"), + &target.key.to_string_lossy(), + ], + )?; println!("added remote {name}"); } } Command::Verify { target, key } => { - let target = Target::resolve(&target, key)?; + let target = + Target::resolve(&target, key, GitRepository::from_environment()?.as_ref())?; let manifest = with_repository!(&target, |repository| repository.verify()?); println!( "verified generation {} ({} refs, {} packs)", @@ -290,7 +309,8 @@ fn main() -> Result<()> { role, key, } => { - let target = Target::resolve(&target, key)?; + let target = + Target::resolve(&target, key, GitRepository::from_environment()?.as_ref())?; let public = PublicDevice::read(&device)?; let device_id = public.device_id.clone(); let roles = DeviceRoles::from(role); @@ -304,13 +324,15 @@ fn main() -> Result<()> { device_id, key, } => { - let target = Target::resolve(&target, key)?; + let target = + Target::resolve(&target, key, GitRepository::from_environment()?.as_ref())?; let (manifest, policy) = with_repository!(&target, |repository| repository .revoke_device(&device_id)?); println!("revoked {device_id}; manifest {manifest}; policy {policy}"); } Command::DeviceList { target, key } => { - let target = Target::resolve(&target, key)?; + let target = + Target::resolve(&target, key, GitRepository::from_environment()?.as_ref())?; let own_id = KeyFile::read(&target.key)?.device_id()?; let devices = with_repository!(&target, |repository| repository.list_devices()?); for device in devices { @@ -337,7 +359,9 @@ fn main() -> Result<()> { discard_newer, accept_stale_floor, } => { - let target = Target::resolve(&target, key)?; + let local = GitRepository::resolve(repo.as_deref())? + .context("recover needs a Git repository; run it inside one or pass --repo")?; + let target = Target::resolve(&target, key, Some(&local))?; let options = RecoveryOptions { publish, base: base.as_deref(), @@ -345,7 +369,7 @@ fn main() -> Result<()> { accept_stale_floor, }; let report = - with_repository!(&target, |repository| repository.recover(&repo, options)?); + with_repository!(&target, |repository| repository.recover(&local, options)?); print_recovery_report(&report); } } @@ -377,16 +401,8 @@ fn default_owner_label() -> String { .unwrap_or_else(|| "owner".to_owned()) } -fn inside_git_repository() -> Result { - Ok(Process::new("git") - .args(["rev-parse", "--git-dir"]) - .output()? - .status - .success()) -} - -fn git_config(key: &str, path: bool) -> Result> { - let mut command = Process::new("git"); +fn git_config(repository: &GitRepository, key: &str, path: bool) -> Result> { + let mut command = repository.git(); command.arg("config"); if path { command.arg("--path"); @@ -399,8 +415,8 @@ fn git_config(key: &str, path: bool) -> Result> { } } -fn run_git(args: &[&str]) -> Result<()> { - let output = Process::new("git").args(args).output()?; +fn run_git(repository: &GitRepository, args: &[&str]) -> Result<()> { + let output = repository.git().args(args).output()?; if !output.status.success() { bail!( "git {} failed: {}", diff --git a/src/repository.rs b/src/repository.rs index 7bc1cf1..f545720 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs; use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; @@ -17,6 +17,7 @@ use crate::error::{ ObjectUnavailable, Unsupported, }; use crate::git; +use crate::gitcmd::{self, GitRepository}; use crate::manifest::{ Manifest, ManifestAuthorization, ManifestHeader, OpenedManifest, PackDescriptor, open_manifest, pack_aad, peek_manifest_header, seal_manifest, unwrap_generation_key, unwrap_predecessor_key, @@ -265,9 +266,9 @@ impl EncryptedRepository { /// Continuity progress is keyed by repository root rather than by Git /// remote name, so `git remote rename` or a second remote for the same /// encrypted repository shares it. - fn open_checkout(&self, repo: &Path) -> Result { - git::ensure_repository(repo)?; - let directory = git_state_directory(repo)? + fn open_checkout(&self, repo: &GitRepository) -> Result { + let directory = repo + .git_dir() .join("git-remote-e2ee") .join(&self.key.repository_root); crate::persist::create_dir_all_durable(&directory) @@ -460,7 +461,7 @@ impl EncryptedRepository { /// manifest ID that holds it. pub fn push( &self, - repo: &Path, + repo: &GitRepository, destination_ref: &str, change: RefChange<'_>, ) -> Result { @@ -575,7 +576,7 @@ impl EncryptedRepository { /// Runs every check `push` would, without publishing (`git push --dry-run`). pub fn check_push( &self, - repo: &Path, + repo: &GitRepository, destination_ref: &str, change: RefChange<'_>, ) -> Result<()> { @@ -586,12 +587,11 @@ impl EncryptedRepository { fn preflight_ref_change( &self, - repo: &Path, + repo: &GitRepository, floor: &FloorGuard, destination_ref: &str, change: RefChange<'_>, ) -> Result<(OpenedState, Option, bool)> { - git::ensure_repository(repo)?; let ref_kind = git::validate_inner_ref(repo, destination_ref)?; let chain = self.pinned_chain(floor)?; let current = chain.first().context("manifest chain is empty")?.clone(); @@ -646,7 +646,7 @@ impl EncryptedRepository { /// Imports the current generation and points `refs/remotes//*` /// and tags at its refs, as `git fetch` through the helper would. - pub fn fetch_into(&self, repo: &Path, remote_name: &str) -> Result { + pub fn fetch_into(&self, repo: &GitRepository, remote_name: &str) -> Result { let manifest = self.import_packs(repo)?; for (reference, object) in &manifest.refs { match git::validate_inner_ref(repo, reference)? { @@ -660,7 +660,7 @@ impl EncryptedRepository { Ok(manifest) } - pub fn observe_manifest(&self, repo: &Path) -> Result { + pub fn observe_manifest(&self, repo: &GitRepository) -> Result { let floor = self.lock_floor()?; let checkout = self.open_checkout(repo)?; let chain = self.pinned_chain(&floor)?; @@ -687,7 +687,7 @@ impl EncryptedRepository { Ok(current.manifest.clone()) } - pub fn import_packs(&self, repo: &Path) -> Result { + pub fn import_packs(&self, repo: &GitRepository) -> Result { let floor = self.lock_floor()?; let checkout = self.open_checkout(repo)?; let chain = self.pinned_chain(&floor)?; @@ -725,7 +725,7 @@ impl EncryptedRepository { /// `after_generation`, recording each pack ID in `imported`. fn import_chain_packs( &self, - repo: &Path, + repo: &GitRepository, chain: &[OpenedState], after_generation: Option, imported: &mut HashSet, @@ -810,7 +810,11 @@ impl EncryptedRepository { Ok(current.manifest.clone()) } - pub fn recover(&self, repo: &Path, options: RecoveryOptions<'_>) -> Result { + pub fn recover( + &self, + repo: &GitRepository, + options: RecoveryOptions<'_>, + ) -> Result { let floor_guard = self.lock_floor()?; let checkout = self.open_checkout(repo)?; let local_floor = floor_guard.read()?; @@ -1315,10 +1319,8 @@ impl EncryptedRepository { fn verify_base_graph(&self, chain: &[OpenedState]) -> Result<()> { let temporary = tempfile::tempdir()?; - let init = std::process::Command::new("git") - .arg("init") - .arg("--bare") - .arg("--quiet") + let init = gitcmd::without_repository() + .args(["init", "--bare", "--quiet"]) .arg(temporary.path()) .output()?; if !init.status.success() { @@ -1327,6 +1329,7 @@ impl EncryptedRepository { String::from_utf8_lossy(&init.stderr).trim() ) } + let scratch = GitRepository::owned_git_dir(temporary.path()); for entry in chain.iter().rev() { for descriptor in &entry.manifest.new_packs { let encrypted = self.storage.open_object(ObjectKind::Pack, &descriptor.id)?; @@ -1337,7 +1340,7 @@ impl EncryptedRepository { SubkeyKind::Pack, descriptor.ordinal, )?; - let mut importer = git::start_pack_import(temporary.path())?; + let mut importer = git::start_pack_import(&scratch)?; open_pack_stream( &pack_key, encrypted, @@ -1354,7 +1357,7 @@ impl EncryptedRepository { } } git::ensure_refs_connected_since( - temporary.path(), + &scratch, &chain .first() .context("selected recovery chain is empty")? @@ -1771,18 +1774,6 @@ fn record_rejected_head( write_json(&path, &log) } -fn git_state_directory(repo: &Path) -> Result { - let output = std::process::Command::new("git") - .arg("-C") - .arg(repo) - .args(["rev-parse", "--absolute-git-dir"]) - .output()?; - if !output.status.success() { - bail!("could not locate Git directory") - } - Ok(String::from_utf8(output.stdout)?.trim().into()) -} - #[cfg(test)] mod tests { use std::io; diff --git a/src/storage.rs b/src/storage.rs index 25a2990..325598e 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::process::{Child, ChildStdin, Stdio}; use std::sync::Mutex; use anyhow::{Context, Result, bail}; @@ -14,6 +14,7 @@ use thiserror::Error; use crate::bounded::{self, Bounded, Opened}; use crate::error::{BudgetExhausted, ObjectUnavailable}; +use crate::gitcmd::{self, GitRepository}; use crate::trace; #[derive(Debug, Error)] @@ -631,6 +632,8 @@ const CARRIER_ROOT_ENTRIES: [&str; 6] = [ "packs", ]; const MAX_RECOVERY_OUTER_COMMITS: usize = 2048; +const CARRIER_COMMIT_MESSAGE: &str = "git-remote-e2ee storage update"; +const CARRIER_IDENTITY: (&str, &str) = ("git-remote-e2ee", "git-remote-e2ee@invalid"); pub struct GitStorage { checkout: tempfile::TempDir, @@ -699,9 +702,8 @@ enum BatchHeader { impl CatFile { fn spawn(repo: &Path) -> Result { - let mut child = carrier_git_command() - .arg("-C") - .arg(repo) + let mut child = GitRepository::owned_work_tree(repo) + .git() .args(["cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -1158,9 +1160,8 @@ impl GitStorage { fn commit_history(&self, limit: usize) -> Result> { let maximum = limit.saturating_add(1).to_string(); - let output = carrier_git_command() - .arg("-C") - .arg(self.root()) + let output = GitRepository::owned_work_tree(self.root()) + .git() .args([ "rev-list", "--parents", @@ -1187,12 +1188,7 @@ impl GitStorage { Ok(commits) } - fn chunk_entries_at( - &self, - commit: &str, - kind: ObjectKind, - id: &str, - ) -> Result>> { + fn chunk_entries_at(&self, commit: &str, kind: ObjectKind, id: &str) -> Result { carrier_chunk_entries(self.root(), commit, kind, id) } @@ -1204,9 +1200,8 @@ impl GitStorage { return Ok(Vec::new()); }; let directory = format!("{}/{}/{id}", kind.directory(), &id[..2]); - let output = carrier_git_command() - .arg("-C") - .arg(self.root()) + let output = GitRepository::owned_work_tree(self.root()) + .git() .args([ "rev-list", &format!("--max-count={}", window.len()), @@ -1236,11 +1231,8 @@ impl GitStorage { id: &str, mut sink: impl FnMut(usize, &[u8]) -> Result<()>, ) -> Result { - let chunks = match self.chunk_entries_at(commit, kind, id) { - Ok(Some(chunks)) => chunks, - Ok(None) => return Ok(false), - Err(error) if error.is::() => return Err(error), - Err(_) => return Ok(false), + let CarrierListing::Chunks(chunks) = self.chunk_entries_at(commit, kind, id)? else { + return Ok(false); }; let mut reader = GitChunkReader::new(self.root(), chunks)?; let mut hasher = Sha256::new(); @@ -1292,19 +1284,27 @@ impl GitStorage { stage_carrier_checkout(self.root(), &cache.path.join("objects"))?; drop(add_timer); let commit_timer = trace::Span::new("carrier_git_commit"); - git_command_with_object_directory( + let objects = cache.path.join("objects"); + let tree = git_text_with_object_directory(self.root(), &objects, &["write-tree"])?; + let mut commit_tree = vec!["commit-tree", "--no-gpg-sign", "-m", CARRIER_COMMIT_MESSAGE]; + if let Some(parent) = base_commit { + commit_tree.extend(["-p", parent]); + } + commit_tree.push(&tree); + let new_commit = git_text_with_object_directory(self.root(), &objects, &commit_tree)?; + git_command( self.root(), - &cache.path.join("objects"), - &["commit", "--quiet", "-m", "git-remote-e2ee storage update"], + &[ + "update-ref", + &format!("refs/heads/{}", self.branch), + &new_commit, + ], )?; - let new_commit = - git_rev_parse(self.root(), "HEAD")?.context("carrier commit was not created")?; drop(commit_timer); let push_timer = trace::Span::new("carrier_git_push"); - let push = carrier_git_command() - .arg("-C") - .arg(self.root()) - .args(["push", "--quiet", "origin"]) + let push = GitRepository::owned_work_tree(self.root()) + .transport() + .args(["push", "--quiet", "--no-verify", "origin"]) .arg(format!("HEAD:refs/heads/{}", self.branch)) .output()?; drop(push_timer); @@ -1356,9 +1356,8 @@ impl GitStorage { Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.into()), _ => {} } - let untracked = carrier_git_command() - .arg("-C") - .arg(root) + let untracked = GitRepository::owned_work_tree(root) + .git() .args([ "ls-files", "--others", @@ -1421,9 +1420,11 @@ impl Storage for GitStorage { let Some(commit) = git_rev_parse(self.root(), "HEAD")? else { bail!(unavailable()) }; - let chunks = self - .chunk_entries_at(&commit, kind, id)? - .ok_or_else(unavailable)?; + let chunks = match self.chunk_entries_at(&commit, kind, id)? { + CarrierListing::Chunks(chunks) => chunks, + CarrierListing::Absent => bail!(unavailable()), + CarrierListing::Malformed(reason) => bail!("carrier object {id}: {reason}"), + }; Ok(Box::new(GitChunkReader::new(self.root(), chunks)?)) } @@ -1672,9 +1673,8 @@ fn remove_unretained_index_objects( keep: &HashSet<&str>, ) -> Result<()> { let tree = kind.directory(); - let output = carrier_git_command() - .arg("-C") - .arg(repo) + let output = GitRepository::owned_work_tree(repo) + .git() .args(["ls-files", "-z", "--", tree]) .output()?; ensure_git_success(&output, "git ls-files carrier recovery objects")?; @@ -1741,20 +1741,29 @@ fn retain_carrier_objects(root: &Path, keep: &HashSet<&str>) -> Result<()> { Ok(()) } +/// What one carrier commit holds for an object. +enum CarrierListing { + Absent, + /// Storage-controlled entries that do not form a valid chunk sequence. + Malformed(&'static str), + Chunks(Vec<(String, String)>), +} + +/// Lists the chunks of `id` at `commit`. Malformed storage content is a +/// `CarrierListing`; only Git or I/O failure is an error. fn carrier_chunk_entries( repo: &Path, commit: &str, kind: ObjectKind, id: &str, -) -> Result>> { +) -> Result { validate_id(id)?; let directory = format!("{}/{}/{id}", kind.directory(), &id[..2]); - let mut child = carrier_git_command() - .arg("-C") - .arg(repo) + let mut child = GitRepository::owned_work_tree(repo) + .git() .args(["ls-tree", "-r", "-z", commit, "--", &directory]) .stdout(Stdio::piped()) - .stderr(Stdio::null()) + .stderr(Stdio::piped()) .spawn()?; let mut listing = Vec::new(); child @@ -1766,15 +1775,25 @@ fn carrier_chunk_entries( if listing.len() as u64 > MAX_CARRIER_LISTING_BYTES { let _ = child.kill(); let _ = child.wait(); - bail!("carrier object listing exceeds its size budget") - } - let status = child.wait()?; - if !status.success() { - bail!("git ls-tree carrier object failed: {status}") + return Ok(CarrierListing::Malformed( + "carrier object listing exceeds its size budget", + )); } + let output = child.wait_with_output()?; + ensure_git_success(&output, "git ls-tree carrier object")?; if listing.is_empty() { - return Ok(None); + return Ok(CarrierListing::Absent); } + Ok(match parse_chunk_listing(&listing, &directory) { + Ok(chunks) => CarrierListing::Chunks(chunks), + Err(reason) => CarrierListing::Malformed(reason), + }) +} + +fn parse_chunk_listing( + listing: &[u8], + directory: &str, +) -> std::result::Result, &'static str> { let mut chunks = Vec::new(); for entry in listing .split(|byte| *byte == 0) @@ -1783,31 +1802,33 @@ fn carrier_chunk_entries( let tab = entry .iter() .position(|byte| *byte == b'\t') - .context("parse carrier tree entry")?; + .ok_or("carrier tree entry has no path")?; let (metadata, path) = (&entry[..tab], &entry[tab + 1..]); - let metadata = std::str::from_utf8(metadata)?; - let path = std::str::from_utf8(path)?.to_owned(); + let metadata = + std::str::from_utf8(metadata).map_err(|_| "carrier tree entry is not UTF-8")?; + let path = std::str::from_utf8(path) + .map_err(|_| "carrier object path is not UTF-8")? + .to_owned(); let mut fields = metadata.split_ascii_whitespace(); if fields.next() != Some("100644") || fields.next() != Some("blob") { - bail!("carrier object contains a non-regular blob") + return Err("carrier object contains a non-regular blob"); } - let blob_id = fields.next().context("carrier blob has no id")?; + let blob_id = fields.next().ok_or("carrier blob has no id")?; if fields.next().is_some() || !path.starts_with(&format!("{directory}/")) { - bail!("unexpected carrier object path") + return Err("unexpected carrier object path"); } chunks.push((path, blob_id.to_owned())); } chunks.sort_by(|left, right| left.0.cmp(&right.0)); if chunks.is_empty() || chunks.len() as u64 > MAX_CHUNKS_PER_OBJECT { - bail!("carrier object has an invalid chunk count") + return Err("carrier object has an invalid chunk count"); } for (index, (path, _)) in chunks.iter().enumerate() { - let expected = format!("{directory}/{index:08}"); - if path != &expected { - bail!("carrier object chunks are not a dense canonical sequence") + if *path != format!("{directory}/{index:08}") { + return Err("carrier object chunks are not a dense canonical sequence"); } } - Ok(Some(chunks)) + Ok(chunks) } struct CarrierCache { @@ -1855,9 +1876,11 @@ impl CarrierCache { drop(fetch_timer); Ok(tip) } - Err(fetch_error) if self.is_corrupt()? => { + Err(FetchError::Local(fetch_error)) if self.is_corrupt()? => { self.rebuild()?; - let result = fetch_carrier_branch(&self.path, remote, branch).with_context(|| { + let result = fetch_carrier_branch(&self.path, remote, branch) + .map_err(FetchError::into_inner) + .with_context(|| { format!( "fetch carrier branch after rebuilding corrupt cache (initial fetch failed: {fetch_error:#})" ) @@ -1865,9 +1888,9 @@ impl CarrierCache { drop(fetch_timer); result } - Err(fetch_error) => { + Err(FetchError::Local(error) | FetchError::Remote(error)) => { drop(fetch_timer); - Err(fetch_error) + Err(error) } } } @@ -1910,7 +1933,7 @@ impl CarrierCache { OsRng.fill_bytes(&mut random); let generation = format!("objects-{}-{}", std::process::id(), hex::encode(random)); self.path = self.remote_dir.join(generation); - let output = carrier_git_command() + let output = gitcmd::without_repository() .args(["init", "--bare", "--quiet"]) .arg(&self.path) .output()?; @@ -1928,10 +1951,25 @@ impl CarrierCache { Ok(!output.status.success()) } + /// Publishes a fresh generation and removes the ones it replaces. A + /// process that opened its checkout before the rebuild and still borrows + /// a removed generation fails its later object reads instead of reading + /// from a corrupt store. fn rebuild(&mut self) -> Result<()> { - // Existing temporary checkouts may still borrow the previous object - // directory. Keep that generation intact and publish a fresh one. - self.initialize_generation() + self.initialize_generation()?; + let current = self.path.file_name().map(ToOwned::to_owned); + for entry in fs::read_dir(&self.remote_dir)? { + let entry = entry?; + let name = entry.file_name(); + let replaced = name.to_str().is_some_and(valid_cache_generation) + && Some(&name) != current.as_ref(); + if replaced { + fs::remove_dir_all(entry.path()).with_context(|| { + format!("remove replaced carrier cache {}", entry.path().display()) + })?; + } + } + Ok(()) } fn publish_current(&self) -> Result<()> { @@ -1992,7 +2030,7 @@ fn parse_carrier_location(location: &str) -> Result<(String, String)> { if branch.is_empty() { bail!("empty carrier branch in {location:?}") } - let check = carrier_git_command() + let check = gitcmd::without_repository() .args(["check-ref-format", &format!("refs/heads/{branch}")]) .output()?; if !check.status.success() || branch.starts_with('-') { @@ -2070,23 +2108,58 @@ fn configure_git_pack_settings(mut configure: impl FnMut(&str, &str) -> Result<( Ok(()) } -fn fetch_carrier_branch(cache: &Path, remote: &str, branch: &str) -> Result> { +/// Why a carrier fetch failed. Only a failure while the remote is reachable +/// can come from the local cache, so only that one justifies a corruption +/// check. +enum FetchError { + Remote(anyhow::Error), + Local(anyhow::Error), +} + +impl FetchError { + fn into_inner(self) -> anyhow::Error { + match self { + Self::Remote(error) | Self::Local(error) => error, + } + } +} + +fn fetch_carrier_branch( + cache: &Path, + remote: &str, + branch: &str, +) -> std::result::Result, FetchError> { let remote_ref = format!("refs/heads/{branch}"); let refspec = format!("+{remote_ref}:{CARRIER_CACHE_FETCH_REF}"); - let fetch = cache_git_output(cache, &["fetch", "--quiet", "--no-tags", remote, &refspec])?; + let fetch = cache_transport_output(cache, &["fetch", "--quiet", "--no-tags", remote, &refspec]) + .map_err(FetchError::Local)?; if fetch.status.success() { - return git_rev_parse(cache, CARRIER_CACHE_FETCH_REF); + return git_rev_parse(cache, CARRIER_CACHE_FETCH_REF).map_err(FetchError::Local); } - let ls_remote = cache_git_output(cache, &["ls-remote", "--heads", remote, &remote_ref])?; - ensure_git_success(&ls_remote, "git ls-remote carrier branch")?; + let ls_remote = cache_transport_output(cache, &["ls-remote", "--heads", remote, &remote_ref]) + .and_then(|output| { + ensure_git_success(&output, "git ls-remote carrier branch")?; + Ok(output) + }) + .map_err(|error| { + FetchError::Remote(error.context(format!( + "git fetch carrier branch failed: {}", + String::from_utf8_lossy(&fetch.stderr).trim() + ))) + })?; if String::from_utf8_lossy(&ls_remote.stdout).trim().is_empty() { - let delete = cache_git_output(cache, &["update-ref", "-d", CARRIER_CACHE_FETCH_REF])?; - ensure_git_success(&delete, "git update-ref delete missing carrier branch")?; + cache_git_output(cache, &["update-ref", "-d", CARRIER_CACHE_FETCH_REF]) + .and_then(|output| { + ensure_git_success(&output, "git update-ref delete missing carrier branch") + }) + .map_err(FetchError::Local)?; return Ok(None); } - ensure_git_success(&fetch, "git fetch carrier branch")?; - unreachable!("failed git fetch must return an error") + Err(FetchError::Local(anyhow::anyhow!( + "git fetch carrier branch failed: {}", + String::from_utf8_lossy(&fetch.stderr).trim() + ))) } fn create_carrier_checkout( @@ -2097,9 +2170,8 @@ fn create_carrier_checkout( base_commit: &Option, ) -> Result<()> { let _timer = trace::Span::new("carrier_checkout_git_setup"); - let output = carrier_git_command() - .arg("-C") - .arg(checkout) + let output = GitRepository::owned_work_tree(checkout) + .git() .args(["init", "--quiet"]) .output()?; ensure_git_success(&output, "git init carrier checkout")?; @@ -2117,11 +2189,6 @@ fn create_carrier_checkout( git_command(checkout, &["config", "gc.auto", "0"])?; git_command(checkout, &["config", "maintenance.auto", "false"])?; configure_git_pack_settings(|key, value| git_command(checkout, &["config", key, value]))?; - git_command(checkout, &["config", "user.name", "git-remote-e2ee"])?; - git_command( - checkout, - &["config", "user.email", "git-remote-e2ee@invalid"], - )?; git_command(checkout, &["remote", "add", "origin", remote])?; let remote_ref = format!("refs/remotes/origin/{branch}"); @@ -2141,16 +2208,19 @@ fn create_carrier_checkout( Ok(()) } +/// `None` when `reference` names nothing; any other Git failure is an error. fn git_rev_parse(repo: &Path, reference: &str) -> Result> { - let output = carrier_git_command() - .arg("-C") - .arg(repo) - .args(["rev-parse", "--verify", reference]) + let output = GitRepository::owned_work_tree(repo) + .git() + .args(["rev-parse", "--verify", "--quiet", reference]) .output()?; - if output.status.success() { - Ok(Some(String::from_utf8(output.stdout)?.trim().to_owned())) - } else { - Ok(None) + match output.status.code() { + Some(0) => Ok(Some(String::from_utf8(output.stdout)?.trim().to_owned())), + Some(1) => Ok(None), + _ => { + ensure_git_success(&output, &format!("git rev-parse {reference}"))?; + unreachable!("a failed rev-parse is an error") + } } } @@ -2160,9 +2230,15 @@ fn cache_git_command(cache: &Path, args: &[&str]) -> Result<()> { } fn cache_git_output(cache: &Path, args: &[&str]) -> Result { - Ok(carrier_git_command() - .arg("--git-dir") - .arg(cache) + Ok(GitRepository::owned_git_dir(cache) + .git() + .args(args) + .output()?) +} + +fn cache_transport_output(cache: &Path, args: &[&str]) -> Result { + Ok(GitRepository::owned_git_dir(cache) + .transport() .args(args) .output()?) } @@ -2178,9 +2254,8 @@ fn ensure_git_success(output: &std::process::Output, action: &str) -> Result<()> } fn git_command(repo: &Path, args: &[&str]) -> Result<()> { - let output = carrier_git_command() - .arg("-C") - .arg(repo) + let output = GitRepository::owned_work_tree(repo) + .git() .args(args) .output()?; if !output.status.success() { @@ -2194,9 +2269,8 @@ fn git_command(repo: &Path, args: &[&str]) -> Result<()> { } fn git_command_with_input(repo: &Path, args: &[&str], input: &[u8]) -> Result<()> { - let mut child = carrier_git_command() - .arg("-C") - .arg(repo) + let mut child = GitRepository::owned_work_tree(repo) + .git() .args(args) .stdin(Stdio::piped()) .stdout(Stdio::null()) @@ -2259,25 +2333,26 @@ fn stage_carrier_checkout(repo: &Path, object_directory: &Path) -> Result<()> { ) } -fn git_command_with_object_directory( +/// Runs a plumbing command that writes objects into the carrier cache and +/// returns its trimmed output. Carrier commits are written this way, with a +/// fixed identity, so user hooks, signing, and identity never apply. +fn git_text_with_object_directory( repo: &Path, object_directory: &Path, args: &[&str], -) -> Result<()> { - let output = carrier_git_command() - .arg("-C") - .arg(repo) +) -> Result { + let (name, email) = CARRIER_IDENTITY; + let output = GitRepository::owned_work_tree(repo) + .git() .env("GIT_OBJECT_DIRECTORY", object_directory) + .env("GIT_AUTHOR_NAME", name) + .env("GIT_AUTHOR_EMAIL", email) + .env("GIT_COMMITTER_NAME", name) + .env("GIT_COMMITTER_EMAIL", email) .args(args) .output()?; - if !output.status.success() { - bail!( - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr).trim() - ) - } - Ok(()) + ensure_git_success(&output, &format!("git {}", args.join(" ")))?; + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) } fn git_command_with_object_directory_input( @@ -2286,9 +2361,8 @@ fn git_command_with_object_directory_input( args: &[&str], input: &[u8], ) -> Result<()> { - let mut child = carrier_git_command() - .arg("-C") - .arg(repo) + let mut child = GitRepository::owned_work_tree(repo) + .git() .env("GIT_OBJECT_DIRECTORY", object_directory) .args(args) .stdin(Stdio::piped()) @@ -2311,22 +2385,6 @@ fn git_command_with_object_directory_input( Ok(()) } -fn carrier_git_command() -> Command { - let mut command = Command::new("git"); - for variable in [ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_COMMON_DIR", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_QUARANTINE_PATH", - ] { - command.env_remove(variable); - } - command -} - #[cfg(test)] mod tests { use std::sync::atomic::{AtomicBool, Ordering}; @@ -2507,7 +2565,10 @@ mod tests { #[cfg(unix)] fn make_fifo(path: &Path) { let _ = fs::remove_file(path); - let status = Command::new("mkfifo").arg(path).status().unwrap(); + let status = std::process::Command::new("mkfifo") + .arg(path) + .status() + .unwrap(); assert!(status.success()); } @@ -2674,4 +2735,57 @@ mod tests { validate_id(&"ab".repeat(32)).unwrap(); assert!(validate_id(&"AB".repeat(32)).is_err()); } + + fn checkout_git(root: &Path, args: &[&str]) -> String { + let output = GitRepository::owned_work_tree(root) + .git() + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + #[test] + fn a_failed_carrier_listing_is_an_error_not_a_missing_copy() { + let checkout = tempfile::tempdir().unwrap(); + checkout_git(checkout.path(), &["init", "--quiet"]); + fs::write(checkout.path().join("FORMAT"), STORAGE_FORMAT).unwrap(); + checkout_git(checkout.path(), &["add", "FORMAT"]); + let tree = checkout_git(checkout.path(), &["write-tree"]); + let commit = checkout_git( + checkout.path(), + &[ + "-c", + "user.name=test", + "-c", + "user.email=test@invalid", + "commit-tree", + "-m", + "carrier", + &tree, + ], + ); + let storage = GitStorage { + checkout, + location: "unused".to_owned(), + remote: "unused".to_owned(), + branch: "main".to_owned(), + state: Mutex::new(GitStorageState { base_commit: None }), + }; + let id = "ab".repeat(32); + let read = + |commit: &str| storage.read_copy_at(commit, ObjectKind::Manifest, &id, |_, _| Ok(())); + + assert!(!read(&commit).unwrap(), "an absent copy is not an error"); + let unknown_commit = "1".repeat(40); + assert!( + read(&unknown_commit).is_err(), + "git ls-tree failing on an unreadable commit must not read as an absent copy" + ); + } } diff --git a/tests/carrier_git.rs b/tests/carrier_git.rs index 9850024..d66540e 100644 --- a/tests/carrier_git.rs +++ b/tests/carrier_git.rs @@ -1,3 +1,4 @@ +use git_remote_e2ee::gitcmd::GitRepository; mod support; use std::collections::BTreeMap; @@ -422,7 +423,7 @@ fn carrier_git_allows_exactly_one_concurrent_writer() { let first = thread::spawn(move || { barrier_a.wait(); repository_a.push( - &source_a, + &GitRepository::open(&source_a).unwrap(), "refs/heads/writer-a", RefChange::Update { source: "refs/heads/main", @@ -434,7 +435,7 @@ fn carrier_git_allows_exactly_one_concurrent_writer() { let second = thread::spawn(move || { barrier_b.wait(); repository_b.push( - &source_b, + &GitRepository::open(&source_b).unwrap(), "refs/heads/writer-b", RefChange::Update { source: "refs/heads/main", @@ -510,14 +511,26 @@ fn stale_carrier_views_on_one_machine_refresh_instead_of_reporting_rollback() { let stale_fetch = open(); let stale_push_main = open(); let stale_push_new = open(); - open().push(&source_a, "refs/heads/main", update).unwrap(); + open() + .push( + &GitRepository::open(&source_a).unwrap(), + "refs/heads/main", + update, + ) + .unwrap(); assert_eq!(support::read_floor(&key.repository_root)["generation"], 1); - let manifest = stale_fetch.fetch_into(&fetched, "private").unwrap(); + let manifest = stale_fetch + .fetch_into(&GitRepository::open(&fetched).unwrap(), "private") + .unwrap(); assert_eq!(manifest.generation, 1); let error = stale_push_main - .push(&source_b, "refs/heads/main", update) + .push( + &GitRepository::open(&source_b).unwrap(), + "refs/heads/main", + update, + ) .unwrap_err(); let rejected_as_fetch_first = error .downcast_ref::() @@ -526,7 +539,11 @@ fn stale_carrier_views_on_one_machine_refresh_instead_of_reporting_rollback() { assert!(rejected_as_fetch_first, "{error:#}"); stale_push_new - .push(&source_b, "refs/heads/writer-b", update) + .push( + &GitRepository::open(&source_b).unwrap(), + "refs/heads/writer-b", + update, + ) .unwrap(); let manifest = open().verify().unwrap(); assert_eq!(manifest.generation, 2); @@ -687,6 +704,16 @@ fn deleted_and_corrupt_carrier_caches_are_rebuilt() { reachable_object_count(&cache_repo(&cache_root)), reachable_object_count(&carrier) ); + let generations: Vec<_> = fs::read_dir(cache.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .filter(|name| name.to_string_lossy().starts_with("objects-")) + .collect(); + assert_eq!( + generations.len(), + 1, + "the rebuild left replaced generations: {generations:?}" + ); } #[test] @@ -890,7 +917,7 @@ fn carrier_git_detects_tampered_ciphertext_chunk() { support::state_dir(), ) .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -989,7 +1016,7 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex let pushed_object = git(&source, &["rev-parse", "HEAD"], false); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1006,7 +1033,7 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex owner.clone(), &returning_machine, ) - .fetch_into(&returning, "private") + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") .unwrap(); let floor_generation: u64 = serde_json::from_slice::( &fs::read( @@ -1065,7 +1092,7 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex let recoverer = EncryptedRepository::new(recovery_storage, owner.clone(), &returning_machine); let report = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: false, base: None, @@ -1086,7 +1113,7 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex let older_base = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: Some(&floor_id), @@ -1105,7 +1132,7 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex let recovered = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1133,8 +1160,12 @@ fn carrier_recovery_chooses_legitimate_descendant_and_restores_deleted_ciphertex let recovered_header = peek_manifest_header(&recovered_bytes).unwrap(); assert!(unwrap_generation_key(&recovered_header, &reader).is_err()); - recoverer.fetch_into(&returning, "private").unwrap(); - recoverer.fetch_into(&fresh, "private").unwrap(); + recoverer + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); + recoverer + .fetch_into(&GitRepository::open(&fresh).unwrap(), "private") + .unwrap(); assert_eq!( git( &fresh, @@ -1210,13 +1241,13 @@ fn carrier_recovery_offers_hidden_legitimate_content_descendant() { owner.clone(), &returning_machine, ) - .fetch_into(&returning, "private") + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") .unwrap(); let pushed_object = git(&source, &["rev-parse", "HEAD"], false); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1264,7 +1295,7 @@ fn carrier_recovery_offers_hidden_legitimate_content_descendant() { ); let report = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: false, base: None, @@ -1282,7 +1313,7 @@ fn carrier_recovery_offers_hidden_legitimate_content_descendant() { let stale_choice = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: Some(&floor_id), @@ -1301,7 +1332,7 @@ fn carrier_recovery_offers_hidden_legitimate_content_descendant() { let recovered = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1315,8 +1346,12 @@ fn carrier_recovery_offers_hidden_legitimate_content_descendant() { assert_eq!(head, recovered_id); assert_eq!(manifest.previous.as_deref(), Some(legitimate_id.as_str())); assert_eq!(manifest.refs["refs/heads/main"], pushed_object); - recoverer.fetch_into(&returning, "private").unwrap(); - recoverer.fetch_into(&fresh, "private").unwrap(); + recoverer + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); + recoverer + .fetch_into(&GitRepository::open(&fresh).unwrap(), "private") + .unwrap(); assert_eq!( git( &fresh, @@ -1372,10 +1407,12 @@ fn carrier_recovery_refuses_history_with_a_merge_hiding_a_legitimate_state() { .unwrap(), ) .unwrap(); - repository.fetch_into(&returning, "private").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1435,7 +1472,7 @@ fn carrier_recovery_refuses_history_with_a_merge_hiding_a_legitimate_state() { ); let error = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1480,7 +1517,9 @@ fn carrier_recovery_refuses_conflicting_authenticated_descendants() { DeviceRoles::reader(), ) .unwrap(); - repository.fetch_into(&returning, "private").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); let policy_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); let policy_encrypted = policy_storage @@ -1529,7 +1568,7 @@ fn carrier_recovery_refuses_conflicting_authenticated_descendants() { let recovery_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); let report = EncryptedRepository::new(recovery_storage, owner, support::state_dir()) .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1594,7 +1633,9 @@ fn carrier_recovery_loses_cas_without_retrying_when_another_writer_publishes() { DeviceRoles::reader(), ) .unwrap(); - repository.fetch_into(&returning, "private").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); let policy_storage = GitStorage::open(carrier.to_str().unwrap()).unwrap(); let policy_encrypted = policy_storage .get_object(ObjectKind::Manifest, &floor_id) @@ -1621,7 +1662,7 @@ fn carrier_recovery_loses_cas_without_retrying_when_another_writer_publishes() { EncryptedRepository::new(stale_storage, owner.clone(), support::state_dir()); let offered = stale_recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: false, base: None, @@ -1647,7 +1688,7 @@ fn carrier_recovery_loses_cas_without_retrying_when_another_writer_publishes() { let error = stale_recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -2174,7 +2215,7 @@ fn carrier_recovery_classifies_missing_head_manifest_as_unavailable() { ); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2182,7 +2223,9 @@ fn carrier_recovery_classifies_missing_head_manifest_as_unavailable() { }, ) .unwrap(); - repository.fetch_into(&returning, "private").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); fs::write(source.join("note.md"), "second content\n").unwrap(); git(&source, &["commit", "-q", "-am", "second"], false); @@ -2193,7 +2236,7 @@ fn carrier_recovery_classifies_missing_head_manifest_as_unavailable() { ); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2219,7 +2262,7 @@ fn carrier_recovery_classifies_missing_head_manifest_as_unavailable() { support::state_dir(), ) .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish, base: None, @@ -2264,7 +2307,7 @@ fn carrier_recovery_refuses_to_skip_a_historical_head_with_missing_objects() { ); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2272,7 +2315,9 @@ fn carrier_recovery_refuses_to_skip_a_historical_head_with_missing_objects() { }, ) .unwrap(); - repository.fetch_into(&returning, "private").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "private") + .unwrap(); let (floor_id, floor) = repository.current_manifest().unwrap(); let uncommitted = GitStorage::open(carrier.to_str().unwrap()).unwrap(); @@ -2311,7 +2356,7 @@ fn carrier_recovery_refuses_to_skip_a_historical_head_with_missing_objects() { support::state_dir(), ) .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -2350,7 +2395,7 @@ fn carrier_history_reads_skip_corrupt_copies_for_an_older_valid_one() { ); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2427,7 +2472,7 @@ fn carrier_historical_restore_charges_rejected_copies_against_its_byte_budget() ); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2507,7 +2552,7 @@ fn recover_junk_head_after_later_writer_push(recover_as_owner: bool) { .unwrap(); open(&writer) .push( - &writer_checkout, + &GitRepository::open(&writer_checkout).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2515,7 +2560,9 @@ fn recover_junk_head_after_later_writer_push(recover_as_owner: bool) { }, ) .unwrap(); - open(&owner).fetch_into(&owner_checkout, "origin").unwrap(); + open(&owner) + .fetch_into(&GitRepository::open(&owner_checkout).unwrap(), "origin") + .unwrap(); let (floor_id, _) = open(&owner).current_manifest().unwrap(); edit_carrier( @@ -2534,7 +2581,7 @@ fn recover_junk_head_after_later_writer_push(recover_as_owner: bool) { let recover = |publish| { open(key) .recover( - checkout, + &GitRepository::open(checkout).unwrap(), RecoveryOptions { publish, base: None, diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 000664c..8b8dccf 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -1,3 +1,4 @@ +use git_remote_e2ee::gitcmd::GitRepository; mod support; use std::collections::BTreeMap; @@ -130,7 +131,9 @@ fn floor_with_malformed_head( let storage = FilesystemStorage::new(temporary.join("storage")); let repository = EncryptedRepository::new(storage.clone(), key.clone(), support::state_dir()); repository.initialize("owner").unwrap(); - repository.fetch_into(&repo, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&repo).unwrap(), "encrypted") + .unwrap(); let floor = storage.read_head().unwrap().unwrap(); let junk = b"malformed successor"; let junk_id = object_id(junk); @@ -158,7 +161,7 @@ fn publish_recovery( repo: &Path, ) -> Result { repository.recover( - repo, + &GitRepository::open(repo).unwrap(), RecoveryOptions { publish: true, base: None, @@ -413,7 +416,7 @@ fn shallow_push_is_rejected_before_publication_and_unshallow_recovers() { let head_before = fs::read(storage.join("HEAD")).unwrap(); let error = encrypted .push( - &shallow, + &GitRepository::open(&shallow).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -427,7 +430,7 @@ fn shallow_push_is_rejected_before_publication_and_unshallow_recovers() { git(&shallow, &["fetch", "--unshallow"]); encrypted .push( - &shallow, + &GitRepository::open(&shallow).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -436,7 +439,9 @@ fn shallow_push_is_rejected_before_publication_and_unshallow_recovers() { ) .unwrap(); initialize_git(&destination); - encrypted.fetch_into(&destination, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); assert_eq!( git(&destination, &["rev-parse", "refs/remotes/encrypted/main"]), second @@ -467,7 +472,7 @@ fn pushes_incrementally_and_fetches_into_another_repository() { let first = commit(&source, "first\n", "first"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -482,7 +487,7 @@ fn pushes_incrementally_and_fetches_into_another_repository() { let second = commit(&source, "second\n", "second"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -495,7 +500,9 @@ fn pushes_incrementally_and_fetches_into_another_repository() { assert_eq!(second_state.total_pack_count, 2); assert_eq!(second_state.refs["refs/heads/main"], second); - encrypted.fetch_into(&destination, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); assert_eq!( git(&destination, &["rev-parse", "refs/remotes/encrypted/main"]), second @@ -529,7 +536,7 @@ fn missing_pack_observation_does_not_advance_import_progress() { commit(&source, "first\n", "first"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -537,14 +544,16 @@ fn missing_pack_observation_does_not_advance_import_progress() { }, ) .unwrap(); - repository.fetch_into(&destination, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); assert_eq!(machine_floor(&destination)["generation"], 1); assert_eq!(progress(&destination)["imported_generation"], 1); commit(&source, "second\n", "second"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -558,18 +567,22 @@ fn missing_pack_observation_does_not_advance_import_progress() { let saved_pack = fs::read(&pack_path).unwrap(); fs::remove_file(&pack_path).unwrap(); - let listed = repository.observe_manifest(&destination).unwrap(); + let listed = repository + .observe_manifest(&GitRepository::open(&destination).unwrap()) + .unwrap(); assert_eq!(listed.generation, 2); assert_eq!(progress(&destination)["imported_generation"], 1); let error = repository - .fetch_into(&destination, "encrypted") + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") .unwrap_err() .to_string(); assert!(error.contains(&pack_id), "unexpected error: {error}"); assert_eq!(progress(&destination)["imported_generation"], 1); write_stored_object(&remote_path, "packs", &pack_id, &saved_pack); - let fetched = repository.fetch_into(&destination, "encrypted").unwrap(); + let fetched = repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); assert_eq!(fetched.generation, 2); assert_eq!(machine_floor(&destination)["generation"], 2); assert_eq!(progress(&destination)["imported_generation"], 2); @@ -592,7 +605,7 @@ fn successful_own_publication_advances_the_client_floor() { commit(&source, "published\n", "published"); let head = repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -625,7 +638,7 @@ fn returning_client_fetches_multiple_offline_generations_from_deltas() { commit(&source, "one\n", "one"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -633,13 +646,15 @@ fn returning_client_fetches_multiple_offline_generations_from_deltas() { }, ) .unwrap(); - repository.fetch_into(&destination, "e2ee").unwrap(); + repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); for value in ["two\n", "three\n", "four\n"] { commit(&source, value, value.trim()); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -648,7 +663,9 @@ fn returning_client_fetches_multiple_offline_generations_from_deltas() { ) .unwrap(); } - let latest = repository.fetch_into(&destination, "e2ee").unwrap(); + let latest = repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); assert_eq!(latest.total_pack_count, 4); assert_eq!( git(&destination, &["show", "refs/remotes/e2ee/main:note.md"]), @@ -674,7 +691,7 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() { commit(&source, "valid\n", "valid"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -682,7 +699,9 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() { }, ) .unwrap(); - repository.fetch_into(&destination, "e2ee").unwrap(); + repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); let head = fs::read_to_string(remote.join("HEAD")) .unwrap() @@ -737,7 +756,7 @@ fn returning_client_rejects_signed_ref_advance_without_its_pack() { fs::write(remote.join("HEAD"), format!("{malicious_id}\n")).unwrap(); let error = repository - .fetch_into(&destination, "e2ee") + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") .unwrap_err() .to_string(); assert!( @@ -764,7 +783,7 @@ fn missing_verified_frontier_tip_forces_full_reverification() { commit(&source, "valid\n", "valid"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -772,7 +791,9 @@ fn missing_verified_frontier_tip_forces_full_reverification() { }, ) .unwrap(); - repository.fetch_into(&destination, "e2ee").unwrap(); + repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); let state_path = state_dir(&destination).join("progress.json"); let mut state: serde_json::Value = @@ -780,7 +801,9 @@ fn missing_verified_frontier_tip_forces_full_reverification() { state["verified_refs"]["refs/heads/main"] = serde_json::Value::String("1".repeat(40)); fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); - let manifest = repository.fetch_into(&destination, "e2ee").unwrap(); + let manifest = repository + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); let state: serde_json::Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); assert_eq!( state["verified_refs"]["refs/heads/main"], @@ -835,7 +858,7 @@ fn local_publication_advances_verified_frontier_before_observation() { let first = commit(&source, "first\n", "first"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -845,13 +868,15 @@ fn local_publication_advances_verified_frontier_before_observation() { .unwrap(); assert_eq!(progress(&source)["verified_refs"]["refs/heads/main"], first); - let observed = repository.observe_manifest(&source).unwrap(); + let observed = repository + .observe_manifest(&GitRepository::open(&source).unwrap()) + .unwrap(); assert_eq!(observed.refs.get("refs/heads/main"), Some(&first)); let second = commit(&source, "second\n", "second"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -892,7 +917,7 @@ fn rejects_non_fast_forward_update() { let first = commit(&source, "first\n", "first"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -903,7 +928,7 @@ fn rejects_non_fast_forward_update() { commit(&source, "second\n", "second"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -916,7 +941,7 @@ fn rejects_non_fast_forward_update() { let error = encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -947,7 +972,7 @@ fn explicit_force_can_publish_a_rollback() { let first = commit(&source, "first\n", "first"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -958,7 +983,7 @@ fn explicit_force_can_publish_a_rollback() { commit(&source, "second\n", "second"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -970,7 +995,7 @@ fn explicit_force_can_publish_a_rollback() { git(&source, &["reset", "--hard", &first]); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -980,7 +1005,9 @@ fn explicit_force_can_publish_a_rollback() { .unwrap(); assert_eq!(encrypted.verify().unwrap().refs["refs/heads/main"], first); - encrypted.fetch_into(&destination, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); assert_eq!( git(&destination, &["rev-parse", "refs/remotes/encrypted/main"]), first @@ -1004,7 +1031,7 @@ fn detects_tampered_pack_ciphertext() { commit(&source, "secret\n", "secret"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1054,7 +1081,7 @@ fn late_stream_authentication_failure_does_not_advance_refs_or_pins() { encrypted.initialize("owner").unwrap(); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1071,7 +1098,11 @@ fn late_stream_authentication_failure_does_not_advance_refs_or_pins() { bytes[last] ^= 1; fs::write(path, bytes).unwrap(); - assert!(encrypted.fetch_into(&destination, "encrypted").is_err()); + assert!( + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .is_err() + ); assert!(!state_dir(&destination).join("progress.json").exists()); let output = Command::new("git") .arg("-C") @@ -1101,7 +1132,7 @@ fn rejects_storage_head_rollback_after_fetch_pins_history() { commit(&source, "first\n", "first"); let first_head = encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1112,7 +1143,7 @@ fn rejects_storage_head_rollback_after_fetch_pins_history() { commit(&source, "second\n", "second"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1120,10 +1151,14 @@ fn rejects_storage_head_rollback_after_fetch_pins_history() { }, ) .unwrap(); - encrypted.fetch_into(&destination, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); fs::write(remote_path.join("HEAD"), format!("{first_head}\n")).unwrap(); - let error = encrypted.fetch_into(&destination, "encrypted").unwrap_err(); + let error = encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap_err(); assert!(error.to_string().contains("rolled back")); } @@ -1147,7 +1182,7 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() { commit(&source, "first\n", "first"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1155,7 +1190,9 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() { }, ) .unwrap(); - encrypted.fetch_into(&destination, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap(); let fork = EncryptedRepository::new( FilesystemStorage::new(&fork_path), @@ -1165,7 +1202,7 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() { fork.initialize("owner").unwrap(); let fork_head = fork .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1176,7 +1213,9 @@ fn rejects_same_generation_manifest_fork_after_fetch_pins_history() { copy_tree(&fork_path.join("manifests"), &remote_path.join("manifests")); fs::write(remote_path.join("HEAD"), format!("{fork_head}\n")).unwrap(); - let error = encrypted.fetch_into(&destination, "encrypted").unwrap_err(); + let error = encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "encrypted") + .unwrap_err(); assert!(error.to_string().contains("history forked")); } @@ -1199,7 +1238,7 @@ fn recover_replaces_reader_signed_junk_from_the_directory_floor() { commit(&source, "recovery keeps this content\n", "base"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1214,7 +1253,9 @@ fn recover_replaces_reader_signed_junk_from_the_directory_floor() { DeviceRoles::reader(), ) .unwrap(); - repository.fetch_into(&returning, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "encrypted") + .unwrap(); let (floor_id, floor_manifest) = repository.current_manifest().unwrap(); let floor_generation = floor_manifest.generation; assert_eq!(machine_floor(&returning)["head_id"], floor_id); @@ -1268,7 +1309,7 @@ fn recover_replaces_reader_signed_junk_from_the_directory_floor() { let recoverer = EncryptedRepository::new(storage.clone(), owner.clone(), support::state_dir()); let blocked = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1297,7 +1338,7 @@ fn recover_replaces_reader_signed_junk_from_the_directory_floor() { let recovered = recoverer .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1316,8 +1357,12 @@ fn recover_replaces_reader_signed_junk_from_the_directory_floor() { let rejected = fs::read(state_dir(&returning).join("rejected-heads.json")).unwrap(); assert!(String::from_utf8_lossy(&rejected).contains(&junk_id)); - recoverer.fetch_into(&returning, "encrypted").unwrap(); - recoverer.fetch_into(&fresh, "encrypted").unwrap(); + recoverer + .fetch_into(&GitRepository::open(&returning).unwrap(), "encrypted") + .unwrap(); + recoverer + .fetch_into(&GitRepository::open(&fresh).unwrap(), "encrypted") + .unwrap(); assert_eq!( git(&fresh, &["show", "refs/remotes/encrypted/main:note.md"]), "recovery keeps this content" @@ -1351,7 +1396,7 @@ fn recover_refuses_a_revoked_devices_unverifiable_head() { let reader_repository = EncryptedRepository::new(storage.clone(), reader.clone(), &reader_machine); reader_repository - .fetch_into(&reader_repo, "encrypted") + .fetch_into(&GitRepository::open(&reader_repo).unwrap(), "encrypted") .unwrap(); let old_floor = fs::read(&reader_floor).unwrap(); owner_repository @@ -1361,7 +1406,7 @@ fn recover_refuses_a_revoked_devices_unverifiable_head() { let report = reader_repository .recover( - &reader_repo, + &GitRepository::open(&reader_repo).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1393,7 +1438,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { EncryptedRepository::new(unknown_storage.clone(), key.clone(), support::state_dir()); unknown_repository.initialize("owner").unwrap(); unknown_repository - .fetch_into(&unknown_repo, "encrypted") + .fetch_into(&GitRepository::open(&unknown_repo).unwrap(), "encrypted") .unwrap(); let floor_id = unknown_storage.read_head().unwrap().unwrap(); let header = serde_json::json!({ @@ -1422,7 +1467,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { .unwrap(); let unsupported = EncryptedRepository::new(unknown_storage.clone(), key, support::state_dir()) .recover( - &unknown_repo, + &GitRepository::open(&unknown_repo).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1453,7 +1498,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { commit(&source, "required floor object\n", "required"); missing_repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1462,7 +1507,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { ) .unwrap(); missing_repository - .fetch_into(&missing_repo, "encrypted") + .fetch_into(&GitRepository::open(&missing_repo).unwrap(), "encrypted") .unwrap(); let floor_id = missing_storage.read_head().unwrap().unwrap(); let floor_manifest = missing_repository.current_manifest().unwrap().1; @@ -1476,7 +1521,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { .unwrap(); let unavailable = missing_repository .recover( - &missing_repo, + &GitRepository::open(&missing_repo).unwrap(), RecoveryOptions { publish: false, base: None, @@ -1497,7 +1542,7 @@ fn recover_refuses_unknown_formats_and_missing_floor_objects() { let refused = EncryptedRepository::new(missing_storage.clone(), missing_key, support::state_dir()) .recover( - &missing_repo, + &GitRepository::open(&missing_repo).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1536,7 +1581,7 @@ fn recover_never_overrides_authenticated_rollback() { commit(&source, "one\n", "one"); let first = repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1547,7 +1592,7 @@ fn recover_never_overrides_authenticated_rollback() { commit(&source, "two\n", "two"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1555,14 +1600,16 @@ fn recover_never_overrides_authenticated_rollback() { }, ) .unwrap(); - repository.fetch_into(&returning, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&returning).unwrap(), "encrypted") + .unwrap(); storage .compare_and_swap_head(Some(&repository.current_manifest().unwrap().0), &first) .unwrap(); let report = repository .recover( - &returning, + &GitRepository::open(&returning).unwrap(), RecoveryOptions { publish: true, base: None, @@ -1589,7 +1636,9 @@ fn recovery_discovery_budget_exhaustion_fails_closed() { let inner = FilesystemStorage::new(&storage_path); let repository = EncryptedRepository::new(inner.clone(), key.clone(), support::state_dir()); repository.initialize("owner").unwrap(); - repository.fetch_into(&repo, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&repo).unwrap(), "encrypted") + .unwrap(); let floor = inner.read_head().unwrap().unwrap(); let invalid_bytes = b"malformed successor"; let invalid_id = object_id(invalid_bytes); @@ -1609,7 +1658,7 @@ fn recovery_discovery_budget_exhaustion_fails_closed() { ); let error = recovery .recover( - &repo, + &GitRepository::open(&repo).unwrap(), RecoveryOptions { publish: false, base: None, @@ -1636,7 +1685,9 @@ fn an_oversized_storage_head_is_invalid_and_recoverable_from_the_floor() { let storage = FilesystemStorage::new(&storage_path); let repository = EncryptedRepository::new(storage.clone(), key, support::state_dir()); repository.initialize("owner").unwrap(); - repository.fetch_into(&repo, "encrypted").unwrap(); + repository + .fetch_into(&GitRepository::open(&repo).unwrap(), "encrypted") + .unwrap(); let floor = storage.read_head().unwrap().unwrap(); fs::write( storage_path.join("HEAD"), @@ -1644,7 +1695,9 @@ fn an_oversized_storage_head_is_invalid_and_recoverable_from_the_floor() { ) .unwrap(); - let error = repository.fetch_into(&repo, "encrypted").unwrap_err(); + let error = repository + .fetch_into(&GitRepository::open(&repo).unwrap(), "encrypted") + .unwrap_err(); assert!( format!("{error:#}").contains("storage HEAD is not a valid head"), "{error:#}" @@ -1678,7 +1731,7 @@ fn writer_can_add_branch_without_having_other_remote_branch_objects() { let main = commit(&first_writer, "main\n", "main"); encrypted .push( - &first_writer, + &GitRepository::open(&first_writer).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1686,7 +1739,9 @@ fn writer_can_add_branch_without_having_other_remote_branch_objects() { }, ) .unwrap(); - encrypted.fetch_into(&second_writer, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&second_writer).unwrap(), "encrypted") + .unwrap(); git( &second_writer, &["switch", "-q", "-c", "other", "refs/remotes/encrypted/main"], @@ -1696,7 +1751,7 @@ fn writer_can_add_branch_without_having_other_remote_branch_objects() { let feature = commit(&first_writer, "feature\n", "feature"); encrypted .push( - &first_writer, + &GitRepository::open(&first_writer).unwrap(), "refs/heads/feature", RefChange::Update { source: "refs/heads/feature", @@ -1708,7 +1763,7 @@ fn writer_can_add_branch_without_having_other_remote_branch_objects() { let other = commit(&second_writer, "other\n", "other"); encrypted .push( - &second_writer, + &GitRepository::open(&second_writer).unwrap(), "refs/heads/other", RefChange::Update { source: "refs/heads/other", @@ -1717,7 +1772,9 @@ fn writer_can_add_branch_without_having_other_remote_branch_objects() { ) .unwrap(); - encrypted.fetch_into(&second_writer, "encrypted").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&second_writer).unwrap(), "encrypted") + .unwrap(); assert_eq!( git( &second_writer, @@ -1757,7 +1814,7 @@ fn deletion_and_tag_publication_race_with_exactly_one_cas_winner() { let main = commit(&source, "main\n", "main"); seed_repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -1768,7 +1825,7 @@ fn deletion_and_tag_publication_race_with_exactly_one_cas_winner() { git(&source, &["branch", "doomed", &main]); seed_repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/doomed", RefChange::Update { source: "refs/heads/doomed", @@ -1794,11 +1851,16 @@ fn deletion_and_tag_publication_race_with_exactly_one_cas_winner() { let tagger = EncryptedRepository::new(storage, key, temporary.path().join("tagger-machine")); let deletion_repo = source.clone(); let tag_repo = source.clone(); - let deletion = - thread::spawn(move || deleter.push(&deletion_repo, "refs/heads/doomed", RefChange::Delete)); + let deletion = thread::spawn(move || { + deleter.push( + &GitRepository::open(&deletion_repo).unwrap(), + "refs/heads/doomed", + RefChange::Delete, + ) + }); let tag_push = thread::spawn(move || { tagger.push( - &tag_repo, + &GitRepository::open(&tag_repo).unwrap(), "refs/tags/race-tag", RefChange::Update { source: "refs/tags/race-tag", @@ -1849,7 +1911,7 @@ fn rejects_unsupported_ref_namespaces_consistently() { commit(&source, "tagged\n", "tagged"); let error = encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/notes/review", RefChange::Update { source: "refs/heads/main", @@ -1960,7 +2022,7 @@ fn directory_root_holds_exactly_the_v6_layout() { commit(&source, "layout\n", "layout"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -2008,7 +2070,7 @@ fn directory_format_mismatch_names_the_found_format() { commit(&source, "format\n", "format"); encrypted .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", diff --git a/tests/git_isolation.rs b/tests/git_isolation.rs new file mode 100644 index 0000000..4b376b6 --- /dev/null +++ b/tests/git_isolation.rs @@ -0,0 +1,321 @@ +//! Which Git repository and configuration each `git` process sees: the +//! user's repository is the one the command names, and repositories the +//! program owns never inherit repository selection, hooks, or signing. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +struct Sandbox { + root: tempfile::TempDir, +} + +impl Sandbox { + fn new() -> Self { + let sandbox = Self { + root: tempfile::tempdir().unwrap(), + }; + fs::write(sandbox.global_config(), "").unwrap(); + sandbox + } + + fn path(&self) -> &Path { + self.root.path() + } + + fn global_config(&self) -> PathBuf { + self.path().join("gitconfig") + } + + fn command(&self, program: &str, workdir: &Path) -> Command { + let helper = Path::new(env!("CARGO_BIN_EXE_git-remote-e2ee")); + let mut paths = vec![helper.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let mut command = Command::new(program); + command + .current_dir(workdir) + .env("PATH", env::join_paths(paths).unwrap()) + .env("XDG_CONFIG_HOME", self.path().join("config")) + .env("XDG_STATE_HOME", self.path().join("state")) + .env("GIT_REMOTE_E2EE_CACHE_DIR", self.path().join("cache")) + .env("GIT_CONFIG_GLOBAL", self.global_config()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_NAME", "E2EE Test") + .env("GIT_AUTHOR_EMAIL", "git-remote-e2ee@example.invalid") + .env("GIT_COMMITTER_NAME", "E2EE Test") + .env("GIT_COMMITTER_EMAIL", "git-remote-e2ee@example.invalid"); + for variable in ["GIT_DIR", "GIT_WORK_TREE", "GIT_TRACE"] { + command.env_remove(variable); + } + command + } + + fn e2ee(&self, workdir: &Path, args: &[&str]) -> Command { + let mut command = self.command(env!("CARGO_BIN_EXE_git-e2ee"), workdir); + command.args(args); + command + } + + fn git(&self, workdir: &Path, args: &[&str]) -> String { + success(&self.command("git", workdir).args(args).output().unwrap()) + } + + fn repository(&self, name: &str) -> PathBuf { + let repo = self.path().join(name); + fs::create_dir_all(&repo).unwrap(); + self.git(&repo, &["init", "-q", "-b", "main"]); + self.git(&repo, &["commit", "-q", "--allow-empty", "-m", "first"]); + repo + } + + fn only_key(&self) -> String { + let keys: Vec<_> = fs::read_dir(self.path().join("config/git-e2ee/keys")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(keys.len(), 1); + keys[0].to_string_lossy().into_owned() + } + + /// A repository whose `origin` is directory storage holding one pushed + /// commit, and whose storage HEAD was then replaced with garbage. + fn repository_with_invalid_storage_head(&self) -> (PathBuf, PathBuf) { + let repo = self.repository("notes"); + let storage = self.path().join("storage"); + let url = format!("e2ee::dir:{}", storage.display()); + success(&self.e2ee(&repo, &["init", &url]).output().unwrap()); + self.git(&repo, &["push", "-q", "origin", "main"]); + fs::write(storage.join("HEAD"), "not a manifest id\n").unwrap(); + (repo, storage) + } +} + +fn success(output: &Output) -> String { + assert!( + output.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()) + .unwrap() + .trim() + .to_owned() +} + +fn object_files(git_dir: &Path) -> Vec { + fn walk(path: &Path, files: &mut Vec) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else { + files.push(path); + } + } + } + let mut files = Vec::new(); + walk(&git_dir.join("objects"), &mut files); + files +} + +#[test] +fn recovery_scratch_repository_ignores_an_inherited_git_dir() { + let sandbox = Sandbox::new(); + let (repo, storage) = sandbox.repository_with_invalid_storage_head(); + let bystander = sandbox.path().join("bystander"); + sandbox.git(sandbox.path(), &["init", "-q", "bystander"]); + let bystander_git = bystander.join(".git"); + let before = object_files(&bystander_git); + + let url = format!("e2ee::dir:{}", storage.display()); + let key = sandbox.only_key(); + let report = success( + &sandbox + .e2ee( + sandbox.path(), + &[ + "recover", + &url, + "--key", + &key, + "--repo", + &repo.to_string_lossy(), + "--publish", + "--accept-stale-floor", + ], + ) + .env("GIT_DIR", &bystander_git) + .output() + .unwrap(), + ); + assert!(report.contains("published recovery manifest"), "{report}"); + assert_eq!( + object_files(&bystander_git), + before, + "recovery wrote into the repository GIT_DIR named" + ); + assert!(!bystander_git.join("git-remote-e2ee").exists()); + assert!(repo.join(".git/git-remote-e2ee").is_dir()); +} + +#[test] +fn recover_resolves_its_target_through_the_named_repository() { + let sandbox = Sandbox::new(); + let (repo, _) = sandbox.repository_with_invalid_storage_head(); + let other = sandbox.repository("other"); + let outside = sandbox.path().join("outside"); + fs::create_dir(&outside).unwrap(); + + for workdir in [&other, &outside] { + let report = success( + &sandbox + .e2ee( + workdir, + &["recover", "origin", "--repo", &repo.to_string_lossy()], + ) + .output() + .unwrap(), + ); + assert!(report.contains("storage head: invalid"), "{report}"); + } + assert!( + repo.join(".git/git-remote-e2ee").is_dir(), + "recovery state belongs to the named repository" + ); + assert!(!other.join(".git/git-remote-e2ee").exists()); +} + +#[cfg(unix)] +#[test] +fn carrier_publication_ignores_user_hooks_and_signing() { + use std::os::unix::fs::PermissionsExt; + + let sandbox = Sandbox::new(); + let repo = sandbox.repository("notes"); + let carrier = sandbox.path().join("carrier.git"); + sandbox.git(sandbox.path(), &["init", "-q", "--bare", "carrier.git"]); + + let failing = |path: &Path| { + fs::write(path, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + }; + let hooks = sandbox.path().join("hooks"); + fs::create_dir(&hooks).unwrap(); + for hook in ["pre-commit", "commit-msg", "post-commit", "pre-push"] { + failing(&hooks.join(hook)); + } + let gpg = sandbox.path().join("failing-gpg"); + failing(&gpg); + fs::write( + sandbox.global_config(), + format!( + "[commit]\n\tgpgSign = true\n[tag]\n\tgpgSign = true\n[gpg]\n\tprogram = {}\n[core]\n\thooksPath = {}\n", + gpg.display(), + hooks.display() + ), + ) + .unwrap(); + + let url = format!("e2ee::{}", carrier.display()); + success(&sandbox.e2ee(&repo, &["init", &url]).output().unwrap()); + sandbox.git(&repo, &["push", "-q", "--no-verify", "origin", "main"]); + + let tip = sandbox.git(&carrier, &["rev-parse", "refs/heads/main"]); + let history = sandbox.git(&carrier, &["rev-list", "--count", &tip]); + assert_eq!(history, "2", "genesis and one push"); + let author = sandbox.git( + &carrier, + &["log", "-1", "--format=%an <%ae> %cn <%ce>", &tip], + ); + assert_eq!( + author, + "git-remote-e2ee git-remote-e2ee " + ); + let signature = sandbox.git(&carrier, &["cat-file", "commit", &tip]); + assert!(!signature.contains("gpgsig"), "{signature}"); +} + +#[test] +fn an_unreachable_carrier_does_not_trigger_a_cache_check() { + let sandbox = Sandbox::new(); + let carrier = sandbox.path().join("carrier.git"); + sandbox.git(sandbox.path(), &["init", "-q", "--bare", "carrier.git"]); + let url = format!("e2ee::{}", carrier.display()); + success( + &sandbox + .e2ee(sandbox.path(), &["init", &url]) + .output() + .unwrap(), + ); + let key = sandbox.only_key(); + fs::rename(&carrier, sandbox.path().join("offline.git")).unwrap(); + + let trace = sandbox.path().join("trace"); + let output = sandbox + .e2ee(sandbox.path(), &["verify", &url, "--key", &key]) + .env("GIT_TRACE", &trace) + .output() + .unwrap(); + assert!(!output.status.success()); + let trace = fs::read_to_string(trace).unwrap(); + assert!(trace.contains("fetch"), "{trace}"); + assert!( + !trace.contains("fsck"), + "an offline fetch ran a cache corruption check: {trace}" + ); +} + +#[test] +fn carrier_transport_keeps_config_injected_with_dash_c_but_not_its_hooks() { + use std::os::unix::fs::PermissionsExt; + + let sandbox = Sandbox::new(); + let carrier = sandbox.path().join("carrier.git"); + sandbox.git(sandbox.path(), &["init", "-q", "--bare", "carrier.git"]); + let key = sandbox.path().join("owner.key.json"); + let key_arg = key.to_string_lossy().into_owned(); + let real_url = format!("e2ee::{}", carrier.display()); + success( + &sandbox + .e2ee(sandbox.path(), &["init", &real_url, "--key", &key_arg]) + .output() + .unwrap(), + ); + + let hooks = sandbox.path().join("hooks"); + fs::create_dir(&hooks).unwrap(); + let pre_push = hooks.join("pre-push"); + fs::write(&pre_push, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(&pre_push, fs::Permissions::from_mode(0o755)).unwrap(); + + let repo = sandbox.repository("notes"); + sandbox.git( + &repo, + &["remote", "add", "private", "e2ee::example-carrier:notes"], + ); + sandbox.git(&repo, &["config", "remote.private.e2ee-key", &key_arg]); + let rewrite = format!("url.{}.insteadOf=example-carrier:notes", carrier.display()); + let hooks_path = format!("core.hooksPath={}", hooks.display()); + sandbox.git( + &repo, + &[ + "-c", + &rewrite, + "-c", + &hooks_path, + "push", + "-q", + "--no-verify", + "private", + "main", + ], + ); + let tip = sandbox.git(&carrier, &["rev-parse", "refs/heads/main"]); + let history = sandbox.git(&carrier, &["rev-list", "--count", &tip]); + assert_eq!( + history, "2", + "genesis and one push through the rewritten URL" + ); +} diff --git a/tests/multi_device.rs b/tests/multi_device.rs index 268ee40..ee4ef1c 100644 --- a/tests/multi_device.rs +++ b/tests/multi_device.rs @@ -1,3 +1,4 @@ +use git_remote_e2ee::gitcmd::GitRepository; use std::fs; use std::path::Path; use std::process::Command; @@ -60,7 +61,7 @@ fn two_devices_can_decrypt_and_push_independently() { let first = commit(&source_a, "from a\n", "from a"); repo_a .push( - &source_a, + &GitRepository::open(&source_a).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -81,7 +82,9 @@ fn two_devices_can_decrypt_and_push_independently() { key_b.clone(), temp.path().join("b-machine"), ); - repo_b.fetch_into(&source_b, "e2ee").unwrap(); + repo_b + .fetch_into(&GitRepository::open(&source_b).unwrap(), "e2ee") + .unwrap(); assert_eq!( git(&source_b, &["rev-parse", "refs/remotes/e2ee/main"]), first @@ -93,7 +96,7 @@ fn two_devices_can_decrypt_and_push_independently() { let second = commit(&source_b, "from b\n", "from b"); repo_b .push( - &source_b, + &GitRepository::open(&source_b).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -104,7 +107,9 @@ fn two_devices_can_decrypt_and_push_independently() { let destination = temp.path().join("destination"); initialize_git(&destination); - repo_a.fetch_into(&destination, "e2ee").unwrap(); + repo_a + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); assert_eq!( git(&destination, &["rev-parse", "refs/remotes/e2ee/main"]), second @@ -132,7 +137,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { let before = commit(&source, "before revoke\n", "before revoke"); repo_a .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -143,7 +148,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { let before_tip = commit(&source, "second pack\n", "second pack"); repo_a .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -165,7 +170,9 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { key_b.clone(), temp.path().join("b-machine"), ); - repo_b.fetch_into(&client_b, "e2ee").unwrap(); + repo_b + .fetch_into(&GitRepository::open(&client_b).unwrap(), "e2ee") + .unwrap(); assert_eq!( git(&client_b, &["show", "refs/remotes/e2ee/main:note.md"]), "second pack" @@ -185,7 +192,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { let after = commit(&source, "after revoke secret\n", "after revoke"); repo_a .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -199,7 +206,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { } let fetch_error = repo_b - .fetch_into(&client_b, "e2ee") + .fetch_into(&GitRepository::open(&client_b).unwrap(), "e2ee") .unwrap_err() .to_string(); assert!( @@ -215,7 +222,7 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { commit(&client_b, "revoked writer\n", "revoked writer"); let push_error = repo_b .push( - &client_b, + &GitRepository::open(&client_b).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -238,7 +245,10 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { temp.path().join("b-machine"), ); old_repo_b - .fetch_into(&snapshot_destination, "snapshot") + .fetch_into( + &GitRepository::open(&snapshot_destination).unwrap(), + "snapshot", + ) .unwrap(); assert_eq!( git( @@ -270,7 +280,9 @@ fn revoked_reader_cannot_reach_later_history_but_keeps_prior_snapshot() { key_c, temp.path().join("c-machine"), ); - let manifest = repo_c.fetch_into(&destination, "e2ee").unwrap(); + let manifest = repo_c + .fetch_into(&GitRepository::open(&destination).unwrap(), "e2ee") + .unwrap(); repo_c.verify().unwrap(); assert_eq!(manifest.total_pack_count, 3); assert_eq!( @@ -308,7 +320,7 @@ fn connected_membership_observation_pins_and_rejects_replay() { commit(&source, "one\n", "one"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -316,7 +328,9 @@ fn connected_membership_observation_pins_and_rejects_replay() { }, ) .unwrap(); - client_view.fetch_into(&client, "e2ee").unwrap(); + client_view + .fetch_into(&GitRepository::open(&client).unwrap(), "e2ee") + .unwrap(); let before_membership = fs::read_to_string(remote.join("HEAD")).unwrap(); // The membership change keeps every ref, so the client already holds a @@ -329,7 +343,9 @@ fn connected_membership_observation_pins_and_rejects_replay() { DeviceRoles::collaborator(), ) .unwrap(); - let observed = client_view.observe_manifest(&client).unwrap(); + let observed = client_view + .observe_manifest(&GitRepository::open(&client).unwrap()) + .unwrap(); assert_eq!(observed.generation, 2); let state_path = client_machine.join(&root).join("continuity.json"); let state: serde_json::Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); @@ -338,7 +354,9 @@ fn connected_membership_observation_pins_and_rejects_replay() { let after_membership = fs::read_to_string(remote.join("HEAD")).unwrap(); fs::write(remote.join("HEAD"), &before_membership).unwrap(); - let error = client_view.observe_manifest(&client).unwrap_err(); + let error = client_view + .observe_manifest(&GitRepository::open(&client).unwrap()) + .unwrap_err(); assert!(format!("{error:#}").contains("rolled back"), "{error:#}"); let state: serde_json::Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); assert_eq!(state["generation"], 2); @@ -347,7 +365,7 @@ fn connected_membership_observation_pins_and_rejects_replay() { commit(&source, "two\n", "two"); repository .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -355,7 +373,9 @@ fn connected_membership_observation_pins_and_rejects_replay() { }, ) .unwrap(); - client_view.fetch_into(&client, "e2ee").unwrap(); + client_view + .fetch_into(&GitRepository::open(&client).unwrap(), "e2ee") + .unwrap(); assert_eq!( git(&client, &["show", "refs/remotes/e2ee/main:note.md"]), "two" diff --git a/tests/read_only_helper.rs b/tests/read_only_helper.rs index cf24efb..6572548 100644 --- a/tests/read_only_helper.rs +++ b/tests/read_only_helper.rs @@ -1,3 +1,4 @@ +use git_remote_e2ee::gitcmd::GitRepository; mod support; use std::env; @@ -272,7 +273,9 @@ fn read_only_device_cannot_publish_recovery() { DeviceRoles::reader(), ) .unwrap(); - owner_repository.fetch_into(&reader_repo, "origin").unwrap(); + owner_repository + .fetch_into(&GitRepository::open(&reader_repo).unwrap(), "origin") + .unwrap(); let (floor_id, _) = owner_repository.current_manifest().unwrap(); let invalid_bytes = b"malformed but content-addressed manifest"; let invalid_id = git_remote_e2ee::crypto::object_id(invalid_bytes); @@ -286,7 +289,7 @@ fn read_only_device_cannot_publish_recovery() { let read_only = EncryptedRepository::new(storage.clone(), reader, support::state_dir()); let report = read_only .recover( - &reader_repo, + &GitRepository::open(&reader_repo).unwrap(), RecoveryOptions { publish: true, base: None, diff --git a/tests/remote_helper.rs b/tests/remote_helper.rs index 01441f1..3f42bf2 100644 --- a/tests/remote_helper.rs +++ b/tests/remote_helper.rs @@ -1,3 +1,4 @@ +use git_remote_e2ee::gitcmd::GitRepository; mod support; use std::env; @@ -170,7 +171,9 @@ fn concurrent_helper_processes_preserve_the_client_state_floor() { encrypted.initialize("owner").unwrap(); configure_remote(&source, &remote_path, &key_path); configure_remote(&destination, &remote_path, &key_path); - encrypted.fetch_into(&destination, "private").unwrap(); + encrypted + .fetch_into(&GitRepository::open(&destination).unwrap(), "private") + .unwrap(); fs::write(source.join("note.md"), "first\n").unwrap(); git(&source, &["add", "note.md"], false); @@ -437,7 +440,7 @@ fn native_git_fetch_rejects_same_generation_manifest_fork() { fork.initialize("owner").unwrap(); let fork_head = fork .push( - &source, + &GitRepository::open(&source).unwrap(), "refs/heads/main", RefChange::Update { source: "refs/heads/main", @@ -610,13 +613,26 @@ fn native_git_fetch_reimports_packs_after_local_objects_are_pruned() { helper_git(&source, &["push", "-q", "private", branch]); } + // Git may run detached auto-maintenance after a fetch, which can repack + // or prune concurrently with the explicit prune below and make the test + // nondeterministic on fast CI runners. Disable it and prune explicitly. + for setting in [ + "maintenance.auto=false", + "gc.auto=0", + "gc.autoDetach=false", + "fetch.writeCommitGraph=false", + ] { + let (key, value) = setting.split_once('=').unwrap(); + git(&destination, &["config", key, value], false); + } helper_git(&destination, &["fetch", "-q", "private"]); git( &destination, &["update-ref", "-d", "refs/remotes/private/feature"], false, ); - git(&destination, &["gc", "-q", "--prune=now"], false); + git(&destination, &["repack", "-a", "-d", "-q"], false); + git(&destination, &["prune", "--expire=now"], false); assert!( !git_output(&destination, &["cat-file", "-e", &heads[1]], false) .status