//! Embedded Git repository management and pass-compatible automatic commits. //! //! Repositories are always opened in isolated mode: only repository-local //! configuration is read, environment variables and global configuration are //! ignored, and no filter, credential helper, or transport program is run. use std::{ collections::{BTreeMap, BTreeSet}, error::Error, fmt, fs, path::{Component, Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, }; use flate2::{Compression, write::ZlibEncoder}; use gix::{ bstr::{BString, ByteSlice}, objs::tree::EntryKind, }; use sha1::{Digest as _, Sha1}; use zeroize::Zeroize as _; #[cfg(feature = "ssh")] use std::io::{Read as _, Write as _}; use crate::{ config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId, SshFingerprint}, crypto::{KeyHandle, KeyStore, SecretProvider}, mutation::{TreeCommit, TreeCommitError, TreeCommitter}, recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, repository::{EncryptedEntry, Repository, SecretBytes}, write::{EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter}, }; const DEFAULT_BRANCH: &str = "main"; const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitIdentity { name: String, email: String, } impl GitIdentity { pub fn ironstorage() -> Self { Self::new("IronStorage", "ironstorage@localhost") .expect("the built-in Git identity is valid") } pub fn new(name: impl Into, email: impl Into) -> Result { let name = name.into(); let email = email.into(); if name.trim().is_empty() || email.trim().is_empty() || name.contains(['\n', '\r', '<', '>']) || email.contains(['\n', '\r', '<', '>']) { return Err(GitError::InvalidIdentity); } Ok(Self { name, email }) } pub fn name(&self) -> &str { &self.name } pub fn email(&self) -> &str { &self.email } fn signature(&self) -> gix::actor::Signature { gix::actor::Signature { name: self.name.clone().into(), email: self.email.clone().into(), time: gix::date::Time::now_local_or_utc(), } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GitChangeKind { Added, Modified, Deleted, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitChange { path: PathBuf, kind: GitChangeKind, } impl GitChange { pub fn path(&self) -> &Path { &self.path } pub fn kind(&self) -> GitChangeKind { self.kind } } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct GitStatus { staged: Vec, unstaged: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GitConflictKind { Content, AddAdd, ModifyDelete, Structural, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitConflict { path: PathBuf, kind: GitConflictKind, } impl GitConflict { pub fn path(&self) -> &Path { &self.path } pub fn kind(&self) -> GitConflictKind { self.kind } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GitConflictChoice { Local, Remote, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitConflictResolution { path: PathBuf, choice: GitConflictChoice, } impl GitConflictResolution { pub fn new(path: PathBuf, choice: GitConflictChoice) -> Self { Self { path, choice } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitRemoteStatus { name: String, url: String, transport: RemoteTransport, ahead: usize, behind: usize, } impl GitRemoteStatus { pub fn name(&self) -> &str { &self.name } pub fn url(&self) -> &str { &self.url } pub const fn transport(&self) -> RemoteTransport { self.transport } pub fn ahead(&self) -> usize { self.ahead } pub fn behind(&self) -> usize { self.behind } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitSnapshot { root: PathBuf, branch: String, status: GitStatus, remote: Option, recent: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitCommitActivity { commit: GitLogEntry, changes: Vec, } impl GitCommitActivity { pub fn commit(&self) -> &GitLogEntry { &self.commit } pub fn changes(&self) -> &[GitChange] { &self.changes } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitDivergence { branch: String, status: GitStatus, remote: GitRemoteStatus, incoming: Vec, outgoing: Vec, } impl GitDivergence { pub fn branch(&self) -> &str { &self.branch } pub fn status(&self) -> &GitStatus { &self.status } pub fn remote(&self) -> &GitRemoteStatus { &self.remote } pub fn incoming(&self) -> &[GitCommitActivity] { &self.incoming } pub fn outgoing(&self) -> &[GitCommitActivity] { &self.outgoing } } impl GitSnapshot { pub fn root(&self) -> &Path { &self.root } pub fn branch(&self) -> &str { &self.branch } pub fn status(&self) -> &GitStatus { &self.status } pub fn remote(&self) -> Option<&GitRemoteStatus> { self.remote.as_ref() } pub fn recent(&self) -> &[GitLogEntry] { &self.recent } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GitProgressPhase { Validating, Authenticating, Receiving, Integrating, Sending, Refreshing, } #[derive(Clone)] pub struct GitOperationControl { cancelled: Arc, progress: Arc, } impl Default for GitOperationControl { fn default() -> Self { Self::new(|_| {}) } } impl GitOperationControl { pub fn new(progress: impl Fn(GitProgressPhase) + Send + Sync + 'static) -> Self { Self { cancelled: Arc::new(AtomicBool::new(false)), progress: Arc::new(progress), } } pub fn cancel(&self) { self.cancelled.store(true, Ordering::Release); } pub fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Acquire) } fn checkpoint(&self, phase: GitProgressPhase) -> Result<(), GitError> { if self.is_cancelled() { return Err(GitError::Cancelled); } (self.progress)(phase); if self.is_cancelled() { Err(GitError::Cancelled) } else { Ok(()) } } pub fn report(&self, phase: GitProgressPhase) -> Result<(), GitError> { self.checkpoint(phase) } } impl GitStatus { pub fn staged(&self) -> &[GitChange] { &self.staged } pub fn unstaged(&self) -> &[GitChange] { &self.unstaged } pub fn is_clean(&self) -> bool { self.staged.is_empty() && self.unstaged.is_empty() } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitLogEntry { id: String, parents: Vec, author_name: String, author_email: String, message: String, timestamp: i64, } impl GitLogEntry { pub fn id(&self) -> &str { &self.id } pub fn parents(&self) -> &[String] { &self.parents } pub fn author_name(&self) -> &str { &self.author_name } pub fn author_email(&self) -> &str { &self.author_email } pub fn message(&self) -> &str { &self.message } pub fn timestamp(&self) -> i64 { self.timestamp } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitDiffEntry { path: PathBuf, old: Option>, new: Option>, } impl GitDiffEntry { pub fn path(&self) -> &Path { &self.path } pub fn old(&self) -> Option<&[u8]> { self.old.as_deref() } pub fn current(&self) -> Option<&[u8]> { self.new.as_deref() } } #[derive(Clone, Eq, PartialEq)] pub struct SshHostKey { host: String, port: u16, algorithm: String, fingerprint: SshFingerprint, encoded: String, } impl SshHostKey { #[cfg(feature = "ssh")] pub(crate) fn new( host: String, port: u16, algorithm: String, fingerprint: SshFingerprint, encoded: String, ) -> Self { Self { host, port, algorithm, fingerprint, encoded, } } pub fn host(&self) -> &str { &self.host } pub const fn port(&self) -> u16 { self.port } pub fn algorithm(&self) -> &str { &self.algorithm } pub const fn fingerprint(&self) -> &SshFingerprint { &self.fingerprint } #[cfg(feature = "ssh")] pub(crate) fn encoded(&self) -> &str { &self.encoded } } impl fmt::Debug for SshHostKey { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("SshHostKey") .field("host", &self.host) .field("port", &self.port) .field("algorithm", &self.algorithm) .field("fingerprint", &self.fingerprint) .finish_non_exhaustive() } } #[derive(Debug, Clone, Eq, PartialEq)] pub enum GitError { NotRepository, AlreadyRepository, BareRepository, InvalidIdentity, InvalidPath { path: PathBuf, }, UnsafeWorktreeObject { path: PathBuf, }, FileTooLarge { path: PathBuf, }, NoChanges, UnbornHead, DirtyWorktree, InvalidRemoteName, ForbiddenRemoteUrl, UnsupportedRemoteTransport { transport: RemoteTransport, }, RemoteNotFound { name: String, }, SshAuthenticationNotConfigured, SshIdentityMissing { path: PathBuf, }, SshIdentityInvalid { path: PathBuf, }, SshUnsupportedAlgorithm { algorithm: String, }, SshKeyPassphraseUnavailable { fingerprint: SshFingerprint, }, SshKeyPassphraseDenied { fingerprint: SshFingerprint, }, SshKeyPassphraseCancelled { fingerprint: SshFingerprint, }, SshKeyPassphraseRejected { fingerprint: SshFingerprint, }, SshAgentUnavailable, SshAgentIdentityMissing { fingerprint: SshFingerprint, }, SshAuthenticationRejected, UnknownSshHostKey { host_key: Box, }, ChangedSshHostKey { host_key: Box, line: usize, }, SshKnownHostsUnavailable { path: PathBuf, }, SshProtocolFailed, SshRemoteServiceFailed { diagnostic: String, }, GitProtocolFailed, MalformedGitPack, RemoteUnpackFailed { diagnostic: String, }, RemoteRefRejected { diagnostic: String, }, PushOutcomeUnknown { diagnostic: String, }, CredentialsUnavailable, CredentialAccessDenied, CredentialCancelled, AuthenticationFailed, NetworkUnavailable, TlsFailed, Cancelled, NonFastForward, MergeConflicts { conflicts: Vec, }, InvalidConflictResolution { path: PathBuf, }, InvalidRepository(String), Io { operation: &'static str, path: PathBuf, }, } impl fmt::Display for GitError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotRepository => formatter.write_str("no Git repository was found"), Self::AlreadyRepository => formatter.write_str("a Git repository already exists here"), Self::BareRepository => { formatter.write_str("a bare Git repository has no password-store worktree") } Self::InvalidIdentity => formatter.write_str("the Git author identity is invalid"), Self::InvalidPath { path } => write!(formatter, "invalid Git path: {}", path.display()), Self::UnsafeWorktreeObject { path } => { write!(formatter, "unsafe worktree object: {}", path.display()) } Self::FileTooLarge { path } => { write!(formatter, "worktree file is too large: {}", path.display()) } Self::NoChanges => formatter.write_str("there are no staged changes to commit"), Self::UnbornHead => formatter.write_str("the repository has no commits"), Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"), Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"), Self::ForbiddenRemoteUrl => { formatter.write_str("the Git remote URL is invalid or forbidden") } Self::UnsupportedRemoteTransport { transport } => { write!( formatter, "this build does not support {transport} Git remotes" ) } Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"), Self::SshAuthenticationNotConfigured => { formatter.write_str("SSH authentication is not configured") } Self::SshIdentityMissing { path } => { write!( formatter, "SSH identity file was not found: {}", path.display() ) } Self::SshIdentityInvalid { path } => { write!( formatter, "SSH identity file is invalid: {}", path.display() ) } Self::SshUnsupportedAlgorithm { algorithm } => { write!(formatter, "unsupported SSH key algorithm: {algorithm}") } Self::SshKeyPassphraseUnavailable { fingerprint } => { write!( formatter, "SSH key passphrase is unavailable for {fingerprint}" ) } Self::SshKeyPassphraseDenied { fingerprint } => { write!( formatter, "access to the SSH key passphrase was denied for {fingerprint}" ) } Self::SshKeyPassphraseCancelled { fingerprint } => { write!( formatter, "SSH key passphrase access was cancelled for {fingerprint}" ) } Self::SshKeyPassphraseRejected { fingerprint } => { write!( formatter, "SSH key passphrase was rejected for {fingerprint}" ) } Self::SshAgentUnavailable => formatter.write_str("the SSH agent is unavailable"), Self::SshAgentIdentityMissing { fingerprint } => { write!(formatter, "the SSH agent does not contain {fingerprint}") } Self::SshAuthenticationRejected => { formatter.write_str("SSH public-key authentication was rejected") } Self::UnknownSshHostKey { host_key } => write!( formatter, "unknown SSH host key for {}:{} ({})", host_key.host, host_key.port, host_key.fingerprint ), Self::ChangedSshHostKey { host_key, line } => write!( formatter, "changed SSH host key for {}:{} ({}, known_hosts line {line})", host_key.host, host_key.port, host_key.fingerprint ), Self::SshKnownHostsUnavailable { path } => write!( formatter, "SSH known-hosts file is unavailable: {}", path.display() ), Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"), Self::SshRemoteServiceFailed { diagnostic } if diagnostic.is_empty() => { formatter.write_str("the remote SSH Git service failed") } Self::SshRemoteServiceFailed { diagnostic } => { write!(formatter, "the remote SSH Git service failed: {diagnostic}") } Self::GitProtocolFailed => formatter.write_str("the Git wire protocol failed"), Self::MalformedGitPack => formatter.write_str("the remote sent a malformed Git pack"), Self::RemoteUnpackFailed { diagnostic } => { write!( formatter, "the remote could not unpack the Git objects: {diagnostic}" ) } Self::RemoteRefRejected { diagnostic } => { write!( formatter, "the remote rejected the Git update: {diagnostic}" ) } Self::PushOutcomeUnknown { diagnostic } if diagnostic.is_empty() => formatter .write_str("the remote Git update outcome is unknown; fetch before retrying"), Self::PushOutcomeUnknown { diagnostic } => write!( formatter, "the remote Git update outcome is unknown; fetch before retrying: {diagnostic}" ), Self::CredentialsUnavailable => { formatter.write_str("HTTPS Git credentials are unavailable") } Self::CredentialAccessDenied => { formatter.write_str("access to HTTPS Git credentials was denied") } Self::CredentialCancelled => { formatter.write_str("HTTPS Git credential authentication was cancelled") } Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"), Self::NetworkUnavailable => formatter.write_str("the Git remote is unavailable"), Self::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"), Self::Cancelled => formatter.write_str("the Git operation was cancelled"), Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"), Self::MergeConflicts { conflicts } => write!( formatter, "the merge has conflicts in {} path(s)", conflicts.len() ), Self::InvalidConflictResolution { path } => write!( formatter, "no supported merge conflict exists at {}", path.display() ), Self::InvalidRepository(message) => { write!(formatter, "invalid Git repository: {message}") } Self::Io { operation, path } => { write!(formatter, "could not {operation} {}", path.display()) } } } } impl Error for GitError {} pub struct GitRepository { root: PathBuf, repository: gix::Repository, identity: GitIdentity, } /// Storage-owned selection of pass-compatible automatic Git commits. pub enum AutomaticEntryCommitter { Git(Box), None(NoGitEntryCommitter), } /// Storage-owned selection of pass-compatible automatic Git commits for a /// recipient policy change. pub enum AutomaticPolicyCommitter { Git(Box), None(crate::recipient::NoGitCommitter), } /// Storage-owned selection of pass-compatible automatic Git commits for a /// remove, move, or copy transaction. pub enum AutomaticTreeCommitter { Git(Box), None(crate::mutation::NoGitTreeCommitter), } impl AutomaticTreeCommitter { pub fn for_source( repository: &Repository, source: &str, identity: GitIdentity, ) -> Result { match GitRepository::open_innermost(repository, Path::new(source), identity) { Ok(git) => Ok(Self::Git(Box::new(git))), Err(GitError::NotRepository) => Ok(Self::None(crate::mutation::NoGitTreeCommitter)), Err(error) => Err(error), } } } impl TreeCommitter for AutomaticTreeCommitter { fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { match self { Self::Git(git) => TreeCommitter::commit(git.as_mut(), change), Self::None(committer) => committer.commit(change), } } } impl AutomaticPolicyCommitter { pub fn for_directory( repository: &Repository, directory: &str, identity: GitIdentity, ) -> Result { match GitRepository::open_innermost(repository, Path::new(directory), identity) { Ok(git) => Ok(Self::Git(Box::new(git))), Err(GitError::NotRepository) => Ok(Self::None(crate::recipient::NoGitCommitter)), Err(error) => Err(error), } } } impl PolicyCommitter for AutomaticPolicyCommitter { fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { match self { Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change), Self::None(committer) => committer.commit(change), } } } impl AutomaticEntryCommitter { pub fn for_entry( repository: &Repository, entry: &str, identity: GitIdentity, ) -> Result { match GitRepository::open_innermost(repository, Path::new(entry), identity) { Ok(git) => Ok(Self::Git(Box::new(git))), Err(GitError::NotRepository) => Ok(Self::None(NoGitEntryCommitter)), Err(error) => Err(error), } } } impl EntryCommitter for AutomaticEntryCommitter { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { match self { Self::Git(git) => EntryCommitter::commit(git.as_mut(), change), Self::None(committer) => committer.commit(change), } } } pub struct GitCredential { username: String, password: crate::repository::SecretBytes, } impl GitCredential { pub fn new(username: impl Into, mut password: Vec) -> Result { let username = username.into(); if username.is_empty() || username.contains(['\n', '\r', '\0']) || password.contains(&b'\n') || password.contains(&b'\r') || password.contains(&0) { password.zeroize(); return Err(GitError::CredentialsUnavailable); } Ok(Self { username, password: crate::repository::SecretBytes::new(password), }) } pub fn username(&self) -> &str { &self.username } pub fn password(&self) -> &[u8] { self.password.expose() } } impl fmt::Debug for GitCredential { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("GitCredential([REDACTED])") } } pub trait GitCredentialProvider { fn credential( &self, server: &ServerId, application: &ApplicationId, ) -> Result; fn credential_for(&self, remote: &GitRemote) -> Result { let (_, server, application) = require_https_remote(remote)?; self.credential(server, application) } } pub trait SshPassphraseProvider { fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result; } pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProvider {} impl GitRemoteCredentialProvider for T {} /// Adds one explicitly supplied SSH key passphrase to an existing credential /// provider without changing HTTPS credential lookup or key selection. pub struct GitRemoteCredentialOverride<'a, P> { provider: &'a P, ssh_passphrase: Option<(&'a SshFingerprint, &'a SecretBytes)>, } impl<'a, P> GitRemoteCredentialOverride<'a, P> { pub const fn new(provider: &'a P) -> Self { Self { provider, ssh_passphrase: None, } } pub const fn with_ssh_passphrase( provider: &'a P, fingerprint: &'a SshFingerprint, passphrase: &'a SecretBytes, ) -> Self { Self { provider, ssh_passphrase: Some((fingerprint, passphrase)), } } } impl GitCredentialProvider for GitRemoteCredentialOverride<'_, P> { fn credential( &self, server: &ServerId, application: &ApplicationId, ) -> Result { self.provider.credential(server, application) } fn credential_for(&self, remote: &GitRemote) -> Result { self.provider.credential_for(remote) } } impl SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> { fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result { if let Some((supplied_fingerprint, passphrase)) = self.ssh_passphrase && supplied_fingerprint == fingerprint { return Ok(SecretBytes::new(passphrase.expose().to_vec())); } self.provider.ssh_key_passphrase(fingerprint) } } #[cfg(feature = "ssh")] pub fn confirm_ssh_host(remote: &GitRemote, host_key: &SshHostKey) -> Result<(), GitError> { let endpoint = remote .endpoint() .as_ssh() .ok_or(GitError::SshAuthenticationNotConfigured)?; let connection = crate::ssh::resolve_connection(endpoint, remote.ssh_authentication())?; if connection.host != host_key.host() || connection.port != host_key.port() { return Err(GitError::SshProtocolFailed); } crate::ssh::persist_confirmed_host(&connection.known_hosts, host_key) } pub trait GitSmartHttpTransport { fn advertise_receive_pack( &self, url: &url::Url, credential: &GitCredential, ) -> Result, GitError>; fn receive_pack( &self, url: &url::Url, credential: &GitCredential, request: Vec, ) -> Result, GitError>; fn advertise_receive_pack_controlled( &self, url: &url::Url, credential: &GitCredential, control: &GitOperationControl, ) -> Result, GitError> { control.checkpoint(GitProgressPhase::Receiving)?; let result = self.advertise_receive_pack(url, credential)?; control.checkpoint(GitProgressPhase::Receiving)?; Ok(result) } fn receive_pack_controlled( &self, url: &url::Url, credential: &GitCredential, request: Vec, control: &GitOperationControl, ) -> Result, GitError> { control.checkpoint(GitProgressPhase::Sending)?; let result = self.receive_pack(url, credential, request)?; control.checkpoint(GitProgressPhase::Sending)?; Ok(result) } } pub trait GitFetchTransport { fn fetch( &self, repository: &GitRepository, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, ) -> Result; fn fetch_controlled( &self, repository: &GitRepository, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { control.checkpoint(GitProgressPhase::Receiving)?; let result = self.fetch(repository, configured, credentials)?; control.checkpoint(GitProgressPhase::Receiving)?; Ok(result) } } #[derive(Default)] pub struct EmbeddedFetchTransport; impl GitFetchTransport for EmbeddedFetchTransport { fn fetch( &self, repository: &GitRepository, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, ) -> Result { repository.fetch_embedded(configured, credentials) } fn fetch_controlled( &self, repository: &GitRepository, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { repository.fetch_embedded_controlled(configured, credentials, control) } } #[derive(Default)] pub struct ReqwestGitTransport; impl ReqwestGitTransport { fn client() -> Result { reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .map_err(invalid) } fn endpoint(url: &url::Url, suffix: &str) -> Result { let mut endpoint = url.clone(); let path = endpoint.path().trim_end_matches('/'); endpoint.set_path(&format!("{path}/{suffix}")); Ok(endpoint) } fn authenticated( request: reqwest::blocking::RequestBuilder, credential: &GitCredential, ) -> Result { let password = std::str::from_utf8(credential.password()) .map_err(|_| GitError::CredentialsUnavailable)?; Ok(request.basic_auth(credential.username(), Some(password))) } fn response(response: reqwest::blocking::Response) -> Result, GitError> { let status = response.status(); if let Some(error) = classify_http_status(status) { return Err(error); } if !status.is_success() { return Err(GitError::InvalidRepository(format!( "Git HTTP server returned {status}" ))); } response .bytes() .map(|bytes| bytes.to_vec()) .map_err(map_reqwest_error) } } impl GitSmartHttpTransport for ReqwestGitTransport { fn advertise_receive_pack( &self, url: &url::Url, credential: &GitCredential, ) -> Result, GitError> { let mut endpoint = Self::endpoint(url, "info/refs")?; endpoint .query_pairs_mut() .append_pair("service", "git-receive-pack"); let request = Self::client()? .get(endpoint) .header("Accept", "application/x-git-receive-pack-advertisement"); Self::response( Self::authenticated(request, credential)? .send() .map_err(map_reqwest_error)?, ) } fn receive_pack( &self, url: &url::Url, credential: &GitCredential, request: Vec, ) -> Result, GitError> { let endpoint = Self::endpoint(url, "git-receive-pack")?; let request = Self::client()? .post(endpoint) .header("Content-Type", "application/x-git-receive-pack-request") .header("Accept", "application/x-git-receive-pack-result") .body(request); Self::response( Self::authenticated(request, credential)? .send() .map_err(map_reqwest_error)?, ) } } struct PushContext { remote: String, branch: String, reference: String, new: gix::hash::ObjectId, } enum ReceivePackClient<'a> { Https { url: &'a url::Url, credential: GitCredential, transport: &'a dyn GitSmartHttpTransport, }, #[cfg(feature = "ssh")] Ssh { session: Option, command: Option, }, } impl ReceivePackClient<'_> { fn advertisement(&mut self, control: &GitOperationControl) -> Result, GitError> { match self { Self::Https { url, credential, transport, } => transport.advertise_receive_pack_controlled(url, credential, control), #[cfg(feature = "ssh")] Self::Ssh { command, .. } => { control.checkpoint(GitProgressPhase::Receiving)?; let result = read_receive_pack_advertisement( &mut command.as_mut().ok_or(GitError::SshProtocolFailed)?.stdout, ); if control.is_cancelled() { return Err(GitError::Cancelled); } let result = result?; control.checkpoint(GitProgressPhase::Receiving)?; Ok(result) } } } fn finish_without_update(&mut self, _control: &GitOperationControl) -> Result<(), GitError> { match self { Self::Https { .. } => Ok(()), #[cfg(feature = "ssh")] Self::Ssh { .. } => { self.exchange_ssh(b"0000".to_vec(), _control, false)?; Ok(()) } } } fn receive( &mut self, request: Vec, control: &GitOperationControl, ) -> Result, GitError> { match self { Self::Https { url, credential, transport, } => transport.receive_pack_controlled(url, credential, request, control), #[cfg(feature = "ssh")] Self::Ssh { .. } => self.exchange_ssh(request, control, true), } } #[cfg(feature = "ssh")] fn exchange_ssh( &mut self, request: Vec, control: &GitOperationControl, update_may_apply: bool, ) -> Result, GitError> { control.checkpoint(GitProgressPhase::Sending)?; let Self::Ssh { session, command } = self else { return Err(GitError::SshProtocolFailed); }; let mut command = command.take().ok_or(GitError::SshProtocolFailed)?; let session = session.take().ok_or(GitError::SshProtocolFailed)?; let write = command.stdin.write_all(&request); drop(command.stdin); if write.is_err() { let _ = session.close(); return Err(if update_may_apply { unknown_push_outcome("SSH channel closed while sending") } else { GitError::SshProtocolFailed }); } let response = read_bounded_to_end(&mut command.stdout); let completion = session.finish_command(command.completion, control); let close = session.close(); let response = if update_may_apply { let response = response.map_err(|_| unknown_push_outcome("SSH channel closed after sending"))?; completion.map_err(map_ambiguous_push_error)?; close.map_err(map_ambiguous_push_error)?; response } else { let response = response.map_err(|_| GitError::SshProtocolFailed)?; completion?; close?; response }; if update_may_apply { control .checkpoint(GitProgressPhase::Sending) .map_err(map_ambiguous_push_error)?; } else { control.checkpoint(GitProgressPhase::Sending)?; } Ok(response) } } #[cfg(feature = "ssh")] fn read_receive_pack_advertisement(input: &mut impl std::io::Read) -> Result, GitError> { let mut output = Vec::new(); for _ in 0..1_000_000 { let mut prefix = [0_u8; 4]; input .read_exact(&mut prefix) .map_err(|_| GitError::GitProtocolFailed)?; let length = std::str::from_utf8(&prefix) .ok() .and_then(|value| usize::from_str_radix(value, 16).ok()) .ok_or(GitError::GitProtocolFailed)?; output.extend_from_slice(&prefix); if length == 0 { return Ok(output); } if !(4..=65_520).contains(&length) || output.len() + length - 4 > 128 * 1024 * 1024 { return Err(GitError::GitProtocolFailed); } let start = output.len(); output.resize(start + length - 4, 0); input .read_exact(&mut output[start..]) .map_err(|_| GitError::GitProtocolFailed)?; } Err(GitError::GitProtocolFailed) } #[cfg(feature = "ssh")] fn read_bounded_to_end(input: &mut impl std::io::Read) -> Result, GitError> { let mut output = Vec::new(); input .take(128 * 1024 * 1024 + 1) .read_to_end(&mut output) .map_err(|_| GitError::SshProtocolFailed)?; if output.len() > 128 * 1024 * 1024 { return Err(GitError::GitProtocolFailed); } Ok(output) } fn protocol_diagnostic(input: &[u8]) -> String { String::from_utf8_lossy(&input[..input.len().min(8 * 1024)]) .chars() .map(|character| { if character.is_control() { ' ' } else { character } }) .collect::() .split_whitespace() .collect::>() .join(" ") } fn unknown_push_outcome(diagnostic: &str) -> GitError { GitError::PushOutcomeUnknown { diagnostic: protocol_diagnostic(diagnostic.as_bytes()), } } #[cfg(feature = "ssh")] fn map_ambiguous_push_error(error: GitError) -> GitError { match error { GitError::SshRemoteServiceFailed { diagnostic } if diagnostic.is_empty() => { unknown_push_outcome("SSH service ended without confirmed status") } GitError::SshRemoteServiceFailed { diagnostic } => unknown_push_outcome(&diagnostic), error => unknown_push_outcome(&error.to_string()), } } fn map_reqwest_error(error: reqwest::Error) -> GitError { if error.is_builder() { return generic_https_error(&error); } classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error)) } fn classify_http_status(status: reqwest::StatusCode) -> Option { matches!( status, reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN ) .then_some(GitError::AuthenticationFailed) } fn classify_structured_https_error(error: &(dyn Error + 'static)) -> Option { let mut current = Some(error); let mut tls = false; let mut network = false; while let Some(error) = current { if let Some(error) = error.downcast_ref::() { if error.status().and_then(classify_http_status).is_some() { return Some(GitError::AuthenticationFailed); } network |= error.is_timeout() || error.is_connect(); } if error.is::() { tls = true; } if let Some(error) = error.downcast_ref::() { if error.kind() == std::io::ErrorKind::PermissionDenied { return Some(GitError::AuthenticationFailed); } network |= matches!( error.kind(), std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::NotConnected | std::io::ErrorKind::TimedOut | std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::NetworkUnreachable | std::io::ErrorKind::HostUnreachable ); } current = error.source(); } if tls { Some(GitError::TlsFailed) } else if network { Some(GitError::NetworkUnavailable) } else { None } } fn generic_https_error(error: &dyn fmt::Display) -> GitError { GitError::InvalidRepository(protocol_diagnostic(error.to_string().as_bytes())) } fn map_https_connect_error(error: gix::remote::connect::Error) -> GitError { match error { error @ gix::remote::connect::Error::Connect(_) => { classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error)) } error => generic_https_error(&error), } } fn map_https_prepare_error(error: gix::remote::fetch::prepare::Error) -> GitError { match error { error @ gix::remote::fetch::prepare::Error::RefMap( gix::remote::ref_map::Error::Transport(_) | gix::remote::ref_map::Error::Handshake(_), ) => classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error)), error => generic_https_error(&error), } } fn map_https_fetch_error( error: gix::remote::fetch::Error, control: &GitOperationControl, ) -> GitError { if control.is_cancelled() { return GitError::Cancelled; } match error { gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ConsumePack(_)) => { GitError::MalformedGitPack } gix::remote::fetch::Error::Fetch( gix::protocol::fetch::Error::FetchResponse(_) | gix::protocol::fetch::Error::Negotiate(_) | gix::protocol::fetch::Error::MissingServerFeature { .. }, ) => GitError::GitProtocolFailed, error @ (gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::Client(_)) | gix::remote::fetch::Error::Client(_)) => { classify_structured_https_error(&error).unwrap_or_else(|| generic_https_error(&error)) } error => generic_https_error(&error), } } #[cfg(feature = "ssh")] fn map_ssh_fetch_error( error: gix::remote::fetch::Error, control: &GitOperationControl, ) -> GitError { if control.is_cancelled() { return GitError::Cancelled; } match error { gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ConsumePack(_)) => { GitError::MalformedGitPack } gix::remote::fetch::Error::Fetch(gix::protocol::fetch::Error::ReadRemainingBytes(_)) | gix::remote::fetch::Error::Client(_) => GitError::SshProtocolFailed, gix::remote::fetch::Error::Fetch( gix::protocol::fetch::Error::FetchResponse(_) | gix::protocol::fetch::Error::Negotiate(_) | gix::protocol::fetch::Error::MissingServerFeature { .. }, ) => GitError::GitProtocolFailed, error => invalid(error), } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct FetchOutcome { remote: String, received_pack: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PullOutcome { UpToDate, FastForward, Merged, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PushOutcome { remote: String, branch: String, old: Option, new: String, } impl PushOutcome { pub fn remote(&self) -> &str { &self.remote } pub fn branch(&self) -> &str { &self.branch } pub fn old(&self) -> Option<&str> { self.old.as_deref() } pub fn new_id(&self) -> &str { &self.new } } impl FetchOutcome { pub fn remote(&self) -> &str { &self.remote } pub fn received_pack(&self) -> bool { self.received_pack } } impl GitRepository { pub fn discover_remote_branches( parent: &Path, identity: GitIdentity, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result, GitError> { Self::discover_remote_branches_with_transport( parent, identity, configured, credentials, &EmbeddedFetchTransport, control, ) } pub fn discover_remote_branches_with_transport( parent: &Path, identity: GitIdentity, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, control: &GitOperationControl, ) -> Result, GitError> { control.report(GitProgressPhase::Validating)?; ensure_remote_transport_available(configured.endpoint())?; ensure_clone_parent(parent)?; let temporary = private_temporary_directory(parent, "probe")?; let result = (|| { let store = Repository::open(&temporary).map_err(invalid)?; gix::ThreadSafeRepository::init_opts( store.root_path(), gix::create::Kind::WithWorktree, gix::create::Options::default(), isolated_options(), ) .map_err(invalid)?; let mut repository = Self::open(&store, identity)?; repository.add_remote(configured.name().as_str(), configured.url())?; repository.fetch_with_transport_controlled( configured, credentials, transport, control, )?; repository.remote_branch_names(configured.name().as_str()) })(); let _ = fs::remove_dir_all(&temporary); result } pub fn clone_into( destination: &Path, identity: GitIdentity, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, ) -> Result { Self::clone_into_with_transport_controlled( destination, identity, configured, None, credentials, &EmbeddedFetchTransport, &GitOperationControl::default(), ) } pub fn clone_into_controlled( destination: &Path, identity: GitIdentity, configured: &GitRemote, branch: &str, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { Self::clone_into_with_transport_controlled( destination, identity, configured, Some(branch), credentials, &EmbeddedFetchTransport, control, ) } pub fn clone_into_with_transport( destination: &Path, identity: GitIdentity, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, ) -> Result { Self::clone_into_with_transport_controlled( destination, identity, configured, None, credentials, transport, &GitOperationControl::default(), ) } pub fn clone_into_with_transport_controlled( destination: &Path, identity: GitIdentity, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, control: &GitOperationControl, ) -> Result { control.report(GitProgressPhase::Validating)?; ensure_remote_transport_available(configured.endpoint())?; if let Some(branch) = branch { validate_remote_name(branch)?; } let parent = destination.parent().ok_or_else(|| GitError::InvalidPath { path: destination.to_owned(), })?; ensure_clone_parent(parent)?; if let Ok(metadata) = fs::symlink_metadata(destination) { if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err(GitError::UnsafeWorktreeObject { path: destination.to_owned(), }); } if fs::read_dir(destination) .map_err(|_| io("read clone destination", destination))? .next() .is_some() { return Err(GitError::DirtyWorktree); } } let temporary = private_temporary_directory(parent, "clone")?; let cloned = (|| { let store = Repository::open(&temporary).map_err(invalid)?; gix::ThreadSafeRepository::init_opts( store.root_path(), gix::create::Kind::WithWorktree, gix::create::Options::default(), isolated_options(), ) .map_err(invalid)?; let mut repository = Self::open(&store, identity.clone())?; repository.add_remote(configured.name().as_str(), configured.url())?; repository.pull_with_transport_controlled( configured, branch, credentials, transport, control, )?; control.report(GitProgressPhase::Refreshing)?; Ok(()) })(); if let Err(error) = cloned { let _ = fs::remove_dir_all(&temporary); return Err(error); } if destination.exists() && fs::remove_dir(destination).is_err() { let _ = fs::remove_dir_all(&temporary); return Err(io("prepare clone destination", destination)); } if fs::rename(&temporary, destination).is_err() { let _ = fs::remove_dir_all(&temporary); return Err(GitError::Io { operation: "install clone at", path: destination.to_owned(), }); } Self::open_at(destination.to_owned(), identity) } fn remote_branch_names(&self, remote: &str) -> Result, GitError> { validate_remote_name(remote)?; let prefix = format!("refs/remotes/{remote}/"); let mut branches = self .repository .references() .map_err(invalid)? .remote_branches() .map_err(invalid)? .filter_map(|reference| { let reference = match reference { Ok(reference) => reference, Err(error) => return Some(Err(invalid(error))), }; let name = match reference.name().as_bstr().to_str() { Ok(name) => name, Err(error) => return Some(Err(invalid(error))), }; name.strip_prefix(&prefix) .filter(|name| *name != "HEAD") .map(|name| { validate_remote_name(name)?; Ok(name.to_owned()) }) }) .collect::, GitError>>()?; branches.sort(); branches.dedup(); if branches.is_empty() { return Err(GitError::InvalidRepository( "the remote has no branches".to_owned(), )); } Ok(branches) } pub fn init(store: &Repository, identity: GitIdentity) -> Result { let root = store.root_path().to_owned(); if root.join(".git").exists() { return Err(GitError::AlreadyRepository); } let attributes_path = root.join(".gitattributes"); let previous_attributes = match fs::read(&attributes_path) { Ok(contents) => Some(contents), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(_) => return Err(io("read", Path::new(".gitattributes"))), }; gix::ThreadSafeRepository::init_opts( &root, gix::create::Kind::WithWorktree, gix::create::Options::default(), isolated_options(), ) .map_err(invalid)?; let initialized = (|| { let repository = Self::open_at(root.clone(), identity)?; let existing = repository .worktree_blob_map()? .keys() .map(PathBuf::from) .collect::>(); if !existing.is_empty() { repository.stage(&existing)?; repository.commit("Add current contents of password store.")?; } atomic_write_worktree(&root, Path::new(".gitattributes"), b"*.gpg diff=gpg\n")?; repository.stage(&[PathBuf::from(".gitattributes")])?; repository.commit("Configure git repository for gpg file diff.")?; Ok(repository) })(); if initialized.is_err() { match previous_attributes { Some(contents) => { let _ = atomic_write_worktree(&root, Path::new(".gitattributes"), &contents); } None => { let _ = fs::remove_file(&attributes_path); } } let _ = fs::remove_dir_all(root.join(".git")); } initialized } pub fn open(store: &Repository, identity: GitIdentity) -> Result { Self::open_at(store.root_path().to_owned(), identity) } /// Open the innermost repository containing `path`, without crossing the store root. pub fn open_innermost( store: &Repository, path: &Path, identity: GitIdentity, ) -> Result { let mut candidate = if path.as_os_str().is_empty() { store.root_path().to_owned() } else { store.root_path().join(validate_relative(path)?) }; if !candidate.is_dir() { candidate.pop(); } loop { if candidate.join(".git").is_dir() { return Self::open_at(candidate, identity); } if candidate == store.root_path() || !candidate.pop() { return Err(GitError::NotRepository); } } } fn open_at(root: PathBuf, identity: GitIdentity) -> Result { let mut repository = gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?; if repository.is_bare() { return Err(GitError::BareRepository); } validate_local_config_security(&load_local_config(&repository)?)?; apply_in_memory_identity(&mut repository, &identity)?; Ok(Self { root, repository, identity, }) } pub fn root(&self) -> &Path { &self.root } pub fn remotes(&self) -> Vec { self.repository .remote_names() .into_iter() .map(|name| name.to_string()) .collect() } pub fn remote_url(&self, name: &str) -> Result { validate_remote_name(name)?; let remote = self .repository .find_remote(name) .map_err(|_| GitError::RemoteNotFound { name: name.to_owned(), })?; let url = remote .url(gix::remote::Direction::Fetch) .ok_or_else(|| GitError::RemoteNotFound { name: name.to_owned(), })?; let text = url.to_bstring().to_str_lossy().into_owned(); RemoteEndpoint::parse(&text).map_err(|_| GitError::ForbiddenRemoteUrl)?; Ok(text) } pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> { validate_remote_name(name)?; ensure_remote_transport_available( &RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?, )?; if self.remotes().iter().any(|existing| existing == name) { return Err(GitError::InvalidRepository(format!( "remote {name} already exists" ))); } self.update_local_config(|config| { config .set_raw_value_by("remote", Some(name.into()), "url", url) .map_err(invalid)?; config .set_raw_value_by( "remote", Some(name.into()), "fetch", format!("+refs/heads/*:refs/remotes/{name}/*"), ) .map_err(invalid)?; Ok(()) }) } pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> { validate_remote_name(name)?; ensure_remote_transport_available( &RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?, )?; if !self.remotes().iter().any(|existing| existing == name) { return Err(GitError::RemoteNotFound { name: name.to_owned(), }); } self.update_local_config(|config| { config .set_raw_value_by("remote", Some(name.into()), "url", url) .map_err(invalid)?; Ok(()) }) } pub fn remove_remote(&mut self, name: &str) -> Result<(), GitError> { validate_remote_name(name)?; self.update_local_config(|config| { if config.remove_section("remote", name).is_none() { return Err(GitError::RemoteNotFound { name: name.to_owned(), }); } Ok(()) }) } pub fn config_get(&self, key: &str) -> Result, GitError> { validate_config_key(key)?; let config = load_local_config(&self.repository)?; match config.raw_value(key) { Ok(value) => Ok(Some(value.to_str_lossy().into_owned())), Err(_) => Ok(None), } } pub fn config_set(&mut self, key: &str, value: &str) -> Result<(), GitError> { validate_config_key(key)?; let parts = key.split('.').collect::>(); if parts .first() .is_some_and(|section| section.eq_ignore_ascii_case("remote")) && parts .last() .is_some_and(|name| name.eq_ignore_ascii_case("url")) { if parts.len() < 3 { return Err(GitError::InvalidRepository( "invalid remote URL configuration key".to_owned(), )); } validate_remote_name(&parts[1..parts.len() - 1].join("."))?; ensure_remote_transport_available( &RemoteEndpoint::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?, )?; } if value.contains(['\n', '\r', '\0']) { return Err(GitError::InvalidRepository( "invalid Git configuration value".to_owned(), )); } self.update_local_config(|config| { config.set_raw_value(key, value).map_err(invalid)?; Ok(()) }) } pub fn fetch( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, ) -> Result { self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport) } pub fn fetch_controlled( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { self.fetch_with_transport_controlled( configured, credentials, &EmbeddedFetchTransport, control, ) } pub fn fetch_with_transport( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, ) -> Result { self.fetch_with_transport_controlled( configured, credentials, transport, &GitOperationControl::default(), ) } pub fn fetch_with_transport_controlled( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, control: &GitOperationControl, ) -> Result { control.checkpoint(GitProgressPhase::Validating)?; let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } control.checkpoint(GitProgressPhase::Authenticating)?; let received_pack = transport.fetch_controlled(self, configured, credentials, control)?; Ok(FetchOutcome { remote: name.to_owned(), received_pack, }) } #[allow( clippy::result_large_err, reason = "the gix credential callback fixes its protocol error type" )] fn fetch_embedded( &self, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, ) -> Result { self.fetch_embedded_controlled(configured, credentials, &GitOperationControl::default()) } #[allow( clippy::result_large_err, reason = "the gix credential callback fixes its protocol error type" )] fn fetch_embedded_controlled( &self, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { match configured.endpoint() { RemoteEndpoint::Https(_) => { let credential = credentials.credential_for(configured)?; self.fetch_embedded_https(configured, &credential, control) } RemoteEndpoint::Ssh(_) => { #[cfg(feature = "ssh")] { self.fetch_embedded_ssh(configured, credentials, control) } #[cfg(not(feature = "ssh"))] { Err(GitError::UnsupportedRemoteTransport { transport: RemoteTransport::Ssh, }) } } } } #[allow( clippy::result_large_err, reason = "the gix credential callback fixes its protocol error type" )] fn fetch_embedded_https( &self, configured: &GitRemote, credential: &GitCredential, control: &GitOperationControl, ) -> Result { let name = configured.name().as_str(); let password = std::str::from_utf8(credential.password()) .map_err(|_| GitError::CredentialsUnavailable)? .to_owned(); let username = credential.username().to_owned(); let expected_origin = require_https_remote(configured)?.0.clone(); let remote = self .repository .find_fetch_remote(Some(name.into())) .map_err(invalid)?; let mut connection = remote .connect(gix::remote::Direction::Fetch) .map_err(map_https_connect_error)?; connection.set_credentials(move |action| match action { gix::credentials::helper::Action::Get(context) => { let same_origin = context .url .as_ref() .and_then(|value| value.to_str().ok()) .and_then(|value| url::Url::parse(value).ok()) .is_some_and(|requested| same_https_origin(&expected_origin, &requested)); if !same_origin { return Err(gix::credentials::protocol::Error::Quit); } Ok(Some(gix::credentials::protocol::Outcome { identity: gix::sec::identity::Account { username: username.clone(), password: password.clone(), oauth_refresh_token: None, }, next: context.into(), })) } gix::credentials::helper::Action::Store(_) | gix::credentials::helper::Action::Erase(_) => Ok(None), }); let prepared = connection .prepare_fetch(gix::progress::Discard, Default::default()) .map_err(map_https_prepare_error)?; let outcome = prepared .receive(gix::progress::Discard, control.cancelled.as_ref()) .map_err(|error| map_https_fetch_error(error, control))?; Ok(matches!( outcome.status, gix::remote::fetch::Status::Change { .. } )) } #[cfg(feature = "ssh")] fn fetch_embedded_ssh( &self, configured: &GitRemote, credentials: &dyn GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { let session = crate::ssh::SshSession::connect(configured, credentials, control)?; let crate::ssh::SshGitCommand { transport, completion, } = session.open_upload_pack(configured, control)?; let fetch = (|| { let remote = self .repository .find_fetch_remote(Some(configured.name().as_str().into())) .map_err(invalid)?; let connection = remote.to_connection_with_transport(transport); let prepared = connection .prepare_fetch(gix::progress::Discard, Default::default()) .map_err(|_| GitError::GitProtocolFailed)?; prepared .receive(gix::progress::Discard, control.cancelled.as_ref()) .map_err(|error| map_ssh_fetch_error(error, control)) })(); let command = session.finish_command(completion, control); let close = session.close(); command?; let outcome = fetch?; close?; Ok(matches!( outcome.status, gix::remote::fetch::Status::Change { .. } )) } pub fn pull( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, ) -> Result { self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport) } pub fn pull_controlled( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { self.pull_with_transport_controlled( configured, branch, credentials, &EmbeddedFetchTransport, control, ) } pub fn pull_with_transport( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, ) -> Result { self.pull_with_transport_controlled( configured, branch, credentials, transport, &GitOperationControl::default(), ) } pub fn pull_with_transport_controlled( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, transport: &impl GitFetchTransport, control: &GitOperationControl, ) -> Result { if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } self.fetch_with_transport_controlled(configured, credentials, transport, control)?; control.checkpoint(GitProgressPhase::Integrating)?; self.integrate_fetched(configured, branch) } /// Integrate an already fetched remote-tracking branch. This is exposed so /// platform networking adapters and deterministic tests can separate the /// transport step from merge/checkout policy. pub fn integrate_fetched( &self, configured: &GitRemote, branch: Option<&str>, ) -> Result { if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } let branch = branch.map_or_else( || self.current_branch(), |branch| { validate_remote_name(branch)?; Ok(branch.to_owned()) }, )?; let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name()); let remote_id = self .repository .find_reference(&remote_ref_name) .map_err(|_| GitError::RemoteNotFound { name: remote_ref_name.clone(), })? .into_fully_peeled_id() .map_err(invalid)? .detach(); let local = self.repository.head_id().ok().map(|id| id.detach()); let Some(local_id) = local else { self.checkout_and_update(remote_id, None)?; return Ok(PullOutcome::FastForward); }; if local_id == remote_id { return Ok(PullOutcome::UpToDate); } let base = self .repository .merge_base(local_id, remote_id) .map_err(invalid)? .detach(); if base == remote_id { return Ok(PullOutcome::UpToDate); } if base == local_id { self.checkout_and_update(remote_id, Some(local_id))?; return Ok(PullOutcome::FastForward); } let labels = gix::merge::blob::builtin_driver::text::Labels { ancestor: Some("base".into()), current: Some("HEAD".into()), other: Some(configured.name().as_str().into()), }; let options = self .repository .tree_merge_options() .map_err(invalid)? .into(); let mut outcome = self .repository .merge_commits(local_id, remote_id, labels, options) .map_err(invalid)?; let unresolved = gix::merge::tree::TreatAsUnresolved::default(); if outcome.tree_merge.has_unresolved_conflicts(unresolved) { let mut conflicts = outcome .tree_merge .conflicts .iter() .filter(|conflict| conflict.is_unresolved(unresolved)) .map(conflict_description) .collect::>(); conflicts.sort_by(|left, right| left.path.cmp(&right.path)); conflicts.dedup_by(|left, right| left.path == right.path); return Err(GitError::MergeConflicts { conflicts }); } let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); let signature = self.identity.signature(); let mut committer_time = gix::date::parse::TimeBuf::default(); let mut author_time = gix::date::parse::TimeBuf::default(); let commit = self .repository .new_commit_as( signature.to_ref(&mut committer_time), signature.to_ref(&mut author_time), format!( "Merge remote-tracking branch '{}/{}'.", configured.name(), branch ), tree, [local_id, remote_id], ) .map_err(invalid)?; self.checkout_and_update(commit.id, Some(local_id))?; Ok(PullOutcome::Merged) } pub fn push( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, ) -> Result { self.push_controlled( configured, branch, credentials, &GitOperationControl::default(), ) } pub fn push_controlled( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result { let context = self.prepare_push(configured, branch, control)?; control.checkpoint(GitProgressPhase::Authenticating)?; match configured.endpoint() { RemoteEndpoint::Https(_) => { let (url, _, _) = require_https_remote(configured)?; let credential = credentials.credential_for(configured)?; let transport = ReqwestGitTransport; let mut client = ReceivePackClient::Https { url, credential, transport: &transport, }; self.push_with_receive_pack(context, &mut client, control) } RemoteEndpoint::Ssh(_) => { #[cfg(feature = "ssh")] { let session = crate::ssh::SshSession::connect(configured, credentials, control)?; let command = session.open_receive_pack(configured, control)?; let mut client = ReceivePackClient::Ssh { session: Some(session), command: Some(command), }; self.push_with_receive_pack(context, &mut client, control) } #[cfg(not(feature = "ssh"))] { Err(GitError::UnsupportedRemoteTransport { transport: RemoteTransport::Ssh, }) } } } } pub fn push_with_transport( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitCredentialProvider, transport: &impl GitSmartHttpTransport, ) -> Result { self.push_with_transport_controlled( configured, branch, credentials, transport, &GitOperationControl::default(), ) } pub fn push_with_transport_controlled( &self, configured: &GitRemote, branch: Option<&str>, credentials: &impl GitCredentialProvider, transport: &impl GitSmartHttpTransport, control: &GitOperationControl, ) -> Result { let context = self.prepare_push(configured, branch, control)?; let (url, _, _) = require_https_remote(configured)?; control.checkpoint(GitProgressPhase::Authenticating)?; let credential = credentials.credential_for(configured)?; let mut client = ReceivePackClient::Https { url, credential, transport, }; self.push_with_receive_pack(context, &mut client, control) } fn prepare_push( &self, configured: &GitRemote, branch: Option<&str>, control: &GitOperationControl, ) -> Result { control.checkpoint(GitProgressPhase::Validating)?; if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } ensure_remote_transport_available(configured.endpoint())?; let branch = branch.map_or_else( || self.current_branch(), |branch| { validate_remote_name(branch)?; Ok(branch.to_owned()) }, )?; let reference = format!("refs/heads/{branch}"); let new = self .repository .head_id() .map_err(|_| GitError::UnbornHead)? .detach(); Ok(PushContext { remote: name.to_owned(), branch, reference, new, }) } fn push_with_receive_pack( &self, context: PushContext, client: &mut ReceivePackClient<'_>, control: &GitOperationControl, ) -> Result { let advertisement = client.advertisement(control)?; let advertised = match parse_receive_pack_advertisement(&advertisement) { Ok(advertised) => advertised, Err(error) => { let _ = client.finish_without_update(control); return Err(error); } }; let old = advertised.refs.get(&context.reference).copied(); if let Some(old) = old { if old == context.new { client.finish_without_update(control)?; self.update_remote_tracking(&context.remote, &context.branch, context.new)?; return Ok(PushOutcome { remote: context.remote, branch: context.branch, old: Some(old.to_string()), new: context.new.to_string(), }); } let base = match self.repository.merge_base(old, context.new) { Ok(base) => base.detach(), Err(_) => { let _ = client.finish_without_update(control); return Err(GitError::NonFastForward); } }; if base != old { let _ = client.finish_without_update(control); return Err(GitError::NonFastForward); } } if !advertised.capabilities.contains("report-status") { let _ = client.finish_without_update(control); return Err(GitError::InvalidRepository( "server does not support receive-pack status reports".to_owned(), )); } let pack = match build_pack(&self.repository, context.new, old) { Ok(pack) => pack, Err(error) => { let _ = client.finish_without_update(control); return Err(error); } }; let old_hex = old.map_or_else( || "0000000000000000000000000000000000000000".to_owned(), |id| id.to_string(), ); let capabilities = if advertised.capabilities.contains("agent") { format!( "report-status agent=ironstorage/{}", env!("CARGO_PKG_VERSION") ) } else { "report-status".to_owned() }; let command = format!( "{old_hex} {} {}\0{capabilities}\n", context.new, context.reference, ); let mut request = encode_pkt_line(command.as_bytes())?; request.extend_from_slice(b"0000"); request.extend_from_slice(&pack); let response = client.receive(request, control)?; parse_receive_pack_result(&response, &context.reference)?; self.update_remote_tracking(&context.remote, &context.branch, context.new)?; Ok(PushOutcome { remote: context.remote, branch: context.branch, old: old.map(|id| id.to_string()), new: context.new.to_string(), }) } pub fn sync( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, ) -> Result<(PullOutcome, PushOutcome), GitError> { let pull = self.pull(configured, None, credentials)?; let push = self.push(configured, None, credentials)?; Ok((pull, push)) } pub fn sync_controlled( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, control: &GitOperationControl, ) -> Result<(PullOutcome, PushOutcome), GitError> { let pull = self.pull_with_transport_controlled( configured, None, credentials, &EmbeddedFetchTransport, control, )?; let push = self.push_controlled(configured, None, credentials, control)?; Ok((pull, push)) } pub fn sync_with_transports_controlled( &self, configured: &GitRemote, credentials: &impl GitRemoteCredentialProvider, fetch: &impl GitFetchTransport, push: &impl GitSmartHttpTransport, control: &GitOperationControl, ) -> Result<(PullOutcome, PushOutcome), GitError> { let pull = self.pull_with_transport_controlled(configured, None, credentials, fetch, control)?; let push = self.push_with_transport_controlled(configured, None, credentials, push, control)?; Ok((pull, push)) } /// Return one internally consistent, secret-free view of repository state /// for frontends. The remote relation uses only the last fetched tracking /// ref and therefore never performs network access. pub fn snapshot( &self, configured: Option<&GitRemote>, recent_limit: usize, ) -> Result { let branch = self.current_branch()?; let status = self.status()?; let recent = match self.log(Some(recent_limit)) { Ok(entries) => entries, Err(GitError::UnbornHead) => Vec::new(), Err(error) => return Err(error), }; let remote = configured .map(|configured| { let name = configured.name().as_str(); let actual_url = if self.remotes().iter().any(|remote| remote == name) { let actual = self.remote_url(name)?; if !same_remote_endpoint(&actual, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } actual } else { configured.url().to_string() }; let remote_ref = format!("refs/remotes/{name}/{branch}"); let relation = self .repository .find_reference(&remote_ref) .ok() .and_then(|reference| reference.into_fully_peeled_id().ok()) .and_then(|remote_id| { self.repository .head_id() .ok() .map(|local_id| (local_id.detach(), remote_id.detach())) }); let (ahead, behind) = relation.map_or(Ok((0, 0)), |(local, remote)| { self.ahead_behind(local, remote) })?; Ok(GitRemoteStatus { name: name.to_owned(), url: actual_url, transport: configured.endpoint().transport(), ahead, behind, }) }) .transpose()?; Ok(GitSnapshot { root: self.root.clone(), branch, status, remote, recent, }) } /// Return commits and typed path changes on each side of the configured /// remote-tracking branch without performing network access. pub fn divergence( &self, configured: &GitRemote, activity_limit: usize, ) -> Result { let branch = self.current_branch()?; let status = self.status()?; let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } let local_id = self .repository .head_id() .map_err(|_| GitError::UnbornHead)? .detach(); let remote_ref = format!("refs/remotes/{name}/{branch}"); let remote_id = self .repository .find_reference(&remote_ref) .map_err(|_| GitError::RemoteNotFound { name: remote_ref.clone(), })? .into_fully_peeled_id() .map_err(invalid)? .detach(); let local_ids = self.ancestor_ids(local_id)?; let remote_ids = self.ancestor_ids(remote_id)?; let ahead = local_ids.difference(&remote_ids).count(); let behind = remote_ids.difference(&local_ids).count(); let incoming = self.commit_activities(remote_id, &local_ids, activity_limit)?; let outgoing = self.commit_activities(local_id, &remote_ids, activity_limit)?; Ok(GitDivergence { branch, status, remote: GitRemoteStatus { name: name.to_owned(), url: actual_url, transport: configured.endpoint().transport(), ahead, behind, }, incoming, outgoing, }) } fn commit_activities( &self, tip: gix::hash::ObjectId, excluded: &BTreeSet, limit: usize, ) -> Result, GitError> { if limit == 0 { return Ok(Vec::new()); } let commit = self .repository .find_object(tip) .map_err(invalid)? .peel_to_commit() .map_err(invalid)?; let mut output = Vec::new(); for info in commit.ancestors().all().map_err(invalid)? { let info = info.map_err(invalid)?; if excluded.contains(&info.id) { continue; } output.push(self.commit_activity(info.id)?); if output.len() >= limit { break; } } Ok(output) } fn commit_activity(&self, id: gix::hash::ObjectId) -> Result { let commit = self .repository .find_object(id) .map_err(invalid)? .peel_to_commit() .map_err(invalid)?; let new_tree = commit.tree_id().map_err(invalid)?.detach(); let old_tree = commit .parent_ids() .next() .map(|parent| { self.repository .find_object(parent.detach()) .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree_id() .map_err(invalid) .map(|tree| tree.detach()) }) .transpose()?; let old = tree_map_by_id(&self.repository, old_tree)?; let new = tree_map_by_id(&self.repository, Some(new_tree))?; Ok(GitCommitActivity { commit: git_log_entry(&commit)?, changes: compare_maps(&old, &new), }) } fn ahead_behind( &self, local: gix::hash::ObjectId, remote: gix::hash::ObjectId, ) -> Result<(usize, usize), GitError> { let local_ids = self.ancestor_ids(local)?; let remote_ids = self.ancestor_ids(remote)?; Ok(( local_ids.difference(&remote_ids).count(), remote_ids.difference(&local_ids).count(), )) } fn ancestor_ids( &self, id: gix::hash::ObjectId, ) -> Result, GitError> { let commit = self .repository .find_object(id) .map_err(invalid)? .peel_to_commit() .map_err(invalid)?; let mut ids = BTreeSet::from([id]); for info in commit.ancestors().all().map_err(invalid)? { ids.insert(info.map_err(invalid)?.id); } Ok(ids) } fn update_remote_tracking( &self, remote: &str, branch: &str, id: gix::hash::ObjectId, ) -> Result<(), GitError> { use gix::refs::{ Target, transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, }; let name = gix::refs::FullName::try_from(format!("refs/remotes/{remote}/{branch}")) .map_err(invalid)?; let edit = RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, force_create_reflog: false, message: "push: update remote-tracking branch".into(), }, expected: PreviousValue::Any, new: Target::Object(id), }, name, deref: false, }; let signature = self.identity.signature(); let mut time = gix::date::parse::TimeBuf::default(); self.repository .edit_references_as(Some(edit), Some(signature.to_ref(&mut time))) .map_err(invalid)?; Ok(()) } /// Resolve every conflict in the current fetched merge using explicit /// whole-path local/remote choices. No repository state is changed unless /// all choices are valid and the merge commit and checkout both succeed. pub fn resolve_fetched( &self, configured: &GitRemote, branch: Option<&str>, resolutions: &[GitConflictResolution], ) -> Result { if !self.status()?.is_clean() { return Err(GitError::DirtyWorktree); } let branch = branch.map_or_else( || self.current_branch(), |branch| { validate_remote_name(branch)?; Ok(branch.to_owned()) }, )?; let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name()); let remote_id = self .repository .find_reference(&remote_ref_name) .map_err(|_| GitError::RemoteNotFound { name: remote_ref_name.clone(), })? .into_fully_peeled_id() .map_err(invalid)? .detach(); let local_id = self .repository .head_id() .map_err(|_| GitError::UnbornHead)? .detach(); let labels = gix::merge::blob::builtin_driver::text::Labels { ancestor: Some("base".into()), current: Some("HEAD".into()), other: Some(configured.name().as_str().into()), }; let options = self .repository .tree_merge_options() .map_err(invalid)? .into(); let mut outcome = self .repository .merge_commits(local_id, remote_id, labels, options) .map_err(invalid)?; let unresolved = gix::merge::tree::TreatAsUnresolved::default(); let choices = resolutions .iter() .map(|resolution| (resolution.path.clone(), resolution.choice)) .collect::>(); let conflicts = outcome .tree_merge .conflicts .iter() .filter(|conflict| conflict.is_unresolved(unresolved)) .collect::>(); for conflict in &conflicts { let description = conflict_description(conflict); let choice = choices.get(&description.path).ok_or_else(|| { GitError::InvalidConflictResolution { path: description.path.clone(), } })?; let entries = conflict.entries(); let selected = match choice { GitConflictChoice::Local => entries[1], GitConflictChoice::Remote => entries[2], }; let path = path_to_git(&description.path)?; let _ = outcome.tree_merge.tree.remove(path.as_bstr()); if let Some(entry) = selected { outcome .tree_merge .tree .upsert(path.as_bstr(), entry.mode.kind(), entry.id) .map_err(invalid)?; } } if choices.len() != conflicts.len() { let extra = choices .keys() .find(|path| { !conflicts .iter() .any(|conflict| conflict_description(conflict).path == **path) }) .cloned() .unwrap_or_default(); return Err(GitError::InvalidConflictResolution { path: extra }); } if conflicts.is_empty() { return self.integrate_fetched(configured, Some(&branch)); } let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); let signature = self.identity.signature(); let mut committer_time = gix::date::parse::TimeBuf::default(); let mut author_time = gix::date::parse::TimeBuf::default(); let commit = self .repository .new_commit_as( signature.to_ref(&mut committer_time), signature.to_ref(&mut author_time), format!( "Merge remote-tracking branch '{}/{}'.", configured.name(), branch ), tree, [local_id, remote_id], ) .map_err(invalid)?; self.checkout_and_update(commit.id, Some(local_id))?; Ok(PullOutcome::Merged) } pub fn current_branch(&self) -> Result { let name = self .repository .head_name() .map_err(invalid)? .ok_or_else(|| { GitError::InvalidRepository("detached HEAD is unsupported".to_owned()) })?; name.as_bstr() .strip_prefix(b"refs/heads/") .and_then(|name| name.to_str().ok()) .map(str::to_owned) .ok_or_else(|| { GitError::InvalidRepository("HEAD does not name a local branch".to_owned()) }) } fn checkout_and_update( &self, commit_id: gix::hash::ObjectId, expected: Option, ) -> Result<(), GitError> { let target_tree = self .repository .find_object(commit_id) .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree_id() .map_err(invalid)? .detach(); let old_tree = expected .map(|id| { self.repository .find_object(id) .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree_id() .map_err(invalid) .map(|id| id.detach()) }) .transpose()?; let previous_index = load_index(&self.repository)?; apply_tree_to_worktree(&self.repository, &self.root, old_tree, target_tree)?; let mut next_index = self .repository .index_from_tree(&target_tree) .map_err(invalid)?; if let Err(error) = next_index.write(Default::default()) { if let Some(old_tree) = old_tree { let _ = apply_tree_to_worktree( &self.repository, &self.root, Some(target_tree), old_tree, ); } return Err(invalid(error)); } let update = self.update_head(commit_id, expected, "pull: update local branch"); if let Err(error) = update { let mut previous_index = previous_index; let _ = previous_index.write(Default::default()); if let Some(old_tree) = old_tree { let _ = apply_tree_to_worktree( &self.repository, &self.root, Some(target_tree), old_tree, ); } return Err(error); } Ok(()) } fn update_head( &self, new: gix::hash::ObjectId, expected: Option, message: impl Into, ) -> Result<(), GitError> { use gix::refs::{ Target, transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, }; let name = self .repository .head_name() .map_err(invalid)? .ok_or_else(|| GitError::InvalidRepository("detached HEAD".to_owned()))?; let expected = expected.map_or(PreviousValue::MustNotExist, |id| { PreviousValue::MustExistAndMatch(Target::Object(id)) }); let edit = RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, force_create_reflog: false, message: message.into(), }, expected, new: Target::Object(new), }, name, deref: true, }; let signature = self.identity.signature(); let mut time = gix::date::parse::TimeBuf::default(); self.repository .edit_references_as(Some(edit), Some(signature.to_ref(&mut time))) .map_err(invalid)?; Ok(()) } fn update_local_config( &mut self, update: impl FnOnce(&mut gix_config::File) -> Result<(), GitError>, ) -> Result<(), GitError> { let mut config = load_local_config(&self.repository)?; update(&mut config)?; validate_local_config_security(&config)?; write_local_config(&self.repository, &config)?; self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?; apply_in_memory_identity(&mut self.repository, &self.identity)?; Ok(()) } pub fn stage(&self, paths: &[PathBuf]) -> Result<(), GitError> { if paths.is_empty() { return Err(GitError::InvalidPath { path: PathBuf::new(), }); } let mut selected = Vec::with_capacity(paths.len()); for path in paths { selected.push(validate_relative(path)?); } let index = load_index(&self.repository)?; let mut editor = self .repository .edit_tree(self.repository.empty_tree().id) .map_err(invalid)?; let mut tracked = Vec::new(); for entry in index.entries() { let path = entry.path(&index); let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| { GitError::InvalidRepository("the index contains an invalid entry mode".to_owned()) })?; editor .upsert(path, mode.kind(), entry.id) .map_err(invalid)?; tracked.push(BString::from(path)); } for path in &selected { let path_bytes = path_to_git(path)?; for tracked_path in &tracked { if matches_path(tracked_path.as_slice(), &path_bytes) { editor.remove(tracked_path.as_bstr()).map_err(invalid)?; } } self.add_worktree_path(&mut editor, path)?; } let tree = editor.write().map_err(invalid)?; let mut next = self.repository.index_from_tree(&tree).map_err(invalid)?; next.write(Default::default()).map_err(invalid)?; Ok(()) } fn add_worktree_path( &self, editor: &mut gix::object::tree::Editor<'_>, path: &Path, ) -> Result<(), GitError> { let absolute = self.root.join(path); let metadata = match fs::symlink_metadata(&absolute) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(_) => return Err(io("inspect", path)), }; if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { return Err(GitError::UnsafeWorktreeObject { path: path.to_owned(), }); } if metadata.is_file() { if metadata.len() > MAX_FILE_BYTES { return Err(GitError::FileTooLarge { path: path.to_owned(), }); } let contents = fs::read(&absolute).map_err(|_| io("read", path))?; let id = self.repository.write_blob(contents).map_err(invalid)?; editor .upsert(BString::from(path_to_git(path)?), EntryKind::Blob, id) .map_err(invalid)?; return Ok(()); } let entries = fs::read_dir(&absolute).map_err(|_| io("read directory", path))?; let mut children = entries .collect::, _>>() .map_err(|_| io("read directory", path))?; children.sort_by_key(std::fs::DirEntry::file_name); for child in children { if child.file_name() == ".git" { continue; } self.add_worktree_path(editor, &path.join(child.file_name()))?; } Ok(()) } pub fn commit(&self, message: &str) -> Result { let index = load_index(&self.repository)?; let tree = tree_from_index(&self.repository, &index)?; self.commit_tree(message, tree) } /// Stage every current worktree/index change and create one user-requested /// commit without exposing staging policy to a frontend. pub fn commit_all(&self, message: &str) -> Result { let status = self.status()?; let paths = status .staged() .iter() .chain(status.unstaged()) .map(|change| change.path().to_owned()) .collect::>() .into_iter() .collect::>(); if paths.is_empty() { return Err(GitError::NoChanges); } self.stage_and_commit(&paths, message)? .ok_or(GitError::NoChanges) } fn commit_tree(&self, message: &str, tree: gix::hash::ObjectId) -> Result { validate_commit_message(message)?; let parent = self.repository.head_id().ok().map(|id| id.detach()); if let Some(parent) = parent { let parent_tree = self .repository .find_object(parent) .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree_id() .map_err(invalid)? .detach(); if parent_tree == tree { return Err(GitError::NoChanges); } } let signature = self.identity.signature(); let mut committer_time = gix::date::parse::TimeBuf::default(); let mut author_time = gix::date::parse::TimeBuf::default(); let parents = parent.into_iter().collect::>(); let id = self .repository .commit_as( signature.to_ref(&mut committer_time), signature.to_ref(&mut author_time), "HEAD", message, tree, parents, ) .map_err(invalid)?; Ok(id.to_string()) } pub fn commit_signed( &self, message: &str, keys: &KeyStore, signer: &KeyHandle, secrets: &mut impl SecretProvider, ) -> Result { let index = load_index(&self.repository)?; let tree = tree_from_index(&self.repository, &index)?; self.commit_tree_signed(message, tree, keys, signer, secrets) } fn commit_tree_signed( &self, message: &str, tree: gix::hash::ObjectId, keys: &KeyStore, signer: &KeyHandle, secrets: &mut impl SecretProvider, ) -> Result { validate_commit_message(message)?; let parent = self.repository.head_id().ok().map(|id| id.detach()); if let Some(parent) = parent { let parent_tree = self .repository .find_object(parent) .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree_id() .map_err(invalid)? .detach(); if parent_tree == tree { return Err(GitError::NoChanges); } } let signature = self.identity.signature(); let mut commit = gix::objs::Commit { tree, parents: parent.into_iter().collect(), author: signature.clone(), committer: signature, encoding: None, message: message.into(), extra_headers: Vec::new(), }; let mut unsigned = Vec::new(); gix::objs::WriteTo::write_to(&commit, &mut unsigned).map_err(invalid)?; let detached = keys.sign(&unsigned, signer, secrets).map_err(invalid)?; let armor = keys.armor_signature(&detached).map_err(invalid)?; commit.extra_headers.push(("gpgsig".into(), armor.into())); let id = self.repository.write_object(&commit).map_err(invalid)?; self.update_head(id.detach(), parent, format!("commit: {message}"))?; Ok(id.to_string()) } pub fn stage_and_commit( &self, paths: &[PathBuf], message: &str, ) -> Result, GitError> { let (previous, tree) = self.prepare_automatic_commit(paths)?; match self.commit_tree(message, tree) { Ok(id) => Ok(Some(id)), Err(GitError::NoChanges) => { restore_index(previous)?; Ok(None) } Err(error) => { restore_index(previous)?; Err(error) } } } pub fn stage_and_commit_signed( &self, paths: &[PathBuf], message: &str, keys: &KeyStore, signer: &KeyHandle, secrets: &mut impl SecretProvider, ) -> Result, GitError> { let (previous, tree) = self.prepare_automatic_commit(paths)?; match self.commit_tree_signed(message, tree, keys, signer, secrets) { Ok(id) => Ok(Some(id)), Err(GitError::NoChanges) => { restore_index(previous)?; Ok(None) } Err(error) => { restore_index(previous)?; Err(error) } } } fn prepare_automatic_commit( &self, paths: &[PathBuf], ) -> Result<(gix::index::File, gix::hash::ObjectId), GitError> { let selected = paths .iter() .map(|path| path_to_git(path)) .collect::, _>>()?; if selected.is_empty() { return Err(GitError::InvalidPath { path: PathBuf::new(), }); } let previous = load_index(&self.repository)?; let prepared = (|| { let head_tree = self .repository .head_commit() .ok() .map(|commit| commit.tree_id().map(|id| id.detach())) .transpose() .map_err(invalid)?; let base_tree = head_tree.unwrap_or_else(|| self.repository.empty_tree().id); let mut base_index = self .repository .index_from_tree(&base_tree) .map_err(invalid)?; base_index.write(Default::default()).map_err(invalid)?; self.stage(paths)?; let selected_index = load_index(&self.repository)?; let commit_tree = tree_from_index(&self.repository, &selected_index)?; let head = self.head_tree_map()?.unwrap_or_default(); let prior = index_map(&previous)?; let mut editor = self.repository.edit_tree(commit_tree).map_err(invalid)?; let paths = head .keys() .chain(prior.keys()) .cloned() .collect::>(); for path in paths { if head.get(&path) == prior.get(&path) || selected .iter() .any(|selected| matches_path(path.as_bytes(), selected)) { continue; } editor.remove(path.as_bytes().as_bstr()).map_err(invalid)?; if let Some(entry) = previous.entry_by_path(path.as_bytes().as_bstr()) { let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| { GitError::InvalidRepository( "the index contains an invalid entry mode".to_owned(), ) })?; editor .upsert(path.as_bytes().as_bstr(), mode.kind(), entry.id) .map_err(invalid)?; } } let retained_tree = editor.write().map_err(invalid)?; let mut retained_index = self .repository .index_from_tree(&retained_tree) .map_err(invalid)?; retained_index.write(Default::default()).map_err(invalid)?; Ok(commit_tree) })(); match prepared { Ok(tree) => Ok((previous, tree)), Err(error) => { restore_index(previous)?; Err(error) } } } pub fn status(&self) -> Result { let index = load_index(&self.repository)?; let index_map = index_map(&index)?; let head_map = self.head_tree_map()?.unwrap_or_default(); let worktree_map = self.worktree_map()?; Ok(GitStatus { staged: compare_maps(&head_map, &index_map), unstaged: compare_maps(&index_map, &worktree_map), }) } pub fn log(&self, maximum: Option) -> Result, GitError> { let head = self .repository .head_commit() .map_err(|_| GitError::UnbornHead)?; let mut output = Vec::new(); for info in head.ancestors().all().map_err(invalid)? { if maximum.is_some_and(|maximum| output.len() >= maximum) { break; } let info = info.map_err(invalid)?; let commit = info.object().map_err(invalid)?; output.push(git_log_entry(&commit)?); } Ok(output) } pub fn diff(&self, paths: &[PathBuf]) -> Result, GitError> { let head = self.head_blob_map()?.unwrap_or_default(); let worktree = self.worktree_blob_map()?; let filter = paths .iter() .map(|p| validate_relative(p)) .collect::, _>>()?; let mut names = head .keys() .chain(worktree.keys()) .cloned() .collect::>(); let mut output = Vec::new(); for name in std::mem::take(&mut names) { if !filter.is_empty() && !filter.iter().any(|path| { matches_path(name.as_bytes(), &path_to_git(path).unwrap_or_default()) }) { continue; } let old = head.get(&name).cloned(); let new = worktree.get(&name).cloned(); if old != new { output.push(GitDiffEntry { path: PathBuf::from(&name), old, new, }); } } Ok(output) } /// Render a helper-free working-tree diff. Password entries are decrypted /// in storage before rendering, so ciphertext is never used as display /// state and no Git textconv or GPG process is required. pub fn render_diff( &self, paths: &[PathBuf], keys: &KeyStore, secrets: &mut impl SecretProvider, ) -> Result { let mut rendered = Vec::new(); for entry in self.diff(paths)? { let display = entry.path().to_string_lossy(); rendered.extend_from_slice( format!("diff --ironstorage a/{display} b/{display}\n").as_bytes(), ); match entry.old() { Some(_) => rendered.extend_from_slice(format!("--- a/{display}\n").as_bytes()), None => rendered.extend_from_slice(b"--- /dev/null\n"), } match entry.current() { Some(_) => rendered.extend_from_slice(format!("+++ b/{display}\n").as_bytes()), None => rendered.extend_from_slice(b"+++ /dev/null\n"), } if let Some(old) = entry.old() { let plaintext = diff_contents(entry.path(), old, keys, secrets)?; append_diff_lines(&mut rendered, b'-', plaintext.expose()); } if let Some(current) = entry.current() { let plaintext = diff_contents(entry.path(), current, keys, secrets)?; append_diff_lines(&mut rendered, b'+', plaintext.expose()); } } Ok(SecretBytes::new(rendered)) } fn head_tree_map(&self) -> Result>, GitError> { let Some(id) = self.repository.head_id().ok() else { return Ok(None); }; let tree = id .object() .map_err(invalid)? .peel_to_commit() .map_err(invalid)? .tree() .map_err(invalid)?; tree_map(tree).map(Some) } fn head_blob_map(&self) -> Result>>, GitError> { let Some(ids) = self.head_tree_map()? else { return Ok(None); }; let mut output = BTreeMap::new(); for (path, id) in ids { output.insert( path, self.repository .find_object(id) .map_err(invalid)? .into_blob() .data .clone(), ); } Ok(Some(output)) } fn worktree_map(&self) -> Result, GitError> { let mut output = BTreeMap::new(); for (path, bytes) in self.worktree_blob_map()? { output.insert( path, gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &bytes) .map_err(invalid)?, ); } Ok(output) } fn worktree_blob_map(&self) -> Result>, GitError> { let mut output = BTreeMap::new(); collect_worktree(&self.root, Path::new(""), &mut output)?; Ok(output) } } fn diff_contents( path: &Path, contents: &[u8], keys: &KeyStore, secrets: &mut impl SecretProvider, ) -> Result { if path.extension().is_some_and(|extension| extension == "gpg") { keys.decrypt(&EncryptedEntry::new(contents.to_vec()), secrets) .map_err(invalid) } else { Ok(SecretBytes::new(contents.to_vec())) } } fn conflict_description(conflict: &gix::merge::tree::Conflict) -> GitConflict { let entries = conflict.entries(); let kind = match ( entries[0].is_some(), entries[1].is_some(), entries[2].is_some(), ) { (false, true, true) => GitConflictKind::AddAdd, (true, false, true) | (true, true, false) => GitConflictKind::ModifyDelete, (true, true, true) if conflict.content_merge().is_some() => GitConflictKind::Content, _ => GitConflictKind::Structural, }; let (ours, theirs) = conflict.changes_in_resolution(); let location = if ours.location().is_empty() { theirs.location() } else { ours.location() }; GitConflict { path: PathBuf::from(location.to_str_lossy().as_ref()), kind, } } fn append_diff_lines(output: &mut Vec, prefix: u8, contents: &[u8]) { if contents.is_empty() { return; } for line in contents.split_inclusive(|byte| *byte == b'\n') { output.push(prefix); output.extend_from_slice(line); if !line.ends_with(b"\n") { output.push(b'\n'); output.extend_from_slice(b"\\ No newline at end of file\n"); } } } fn git_log_entry(commit: &gix::Commit<'_>) -> Result { let decoded = commit.decode().map_err(invalid)?; let author = decoded.author().map_err(invalid)?; Ok(GitLogEntry { id: commit.id.to_string(), parents: decoded.parents().map(|id| id.to_string()).collect(), author_name: author.name.to_str_lossy().into_owned(), author_email: author.email.to_str_lossy().into_owned(), message: decoded.message.to_str_lossy().into_owned(), timestamp: author.time().map_err(invalid)?.seconds, }) } impl EntryCommitter for GitRepository { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message()) .map(|_| ()) .map_err(|error| EntryCommitError::new(error.to_string())) } } impl PolicyCommitter for GitRepository { fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { self.stage_and_commit(&change.changed_paths(), change.message()) .map(|_| ()) .map_err(|error| PolicyCommitError::new(error.to_string())) } } impl TreeCommitter for GitRepository { fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { self.stage_and_commit(change.changed_paths(), change.message()) .map(|_| ()) .map_err(|error| TreeCommitError::new(error.to_string())) } } fn isolated_options() -> gix::open::Options { gix::open::Options::isolated().config_overrides([ format!("init.defaultBranch={DEFAULT_BRANCH}"), "core.autocrlf=false".to_owned(), "core.symlinks=false".to_owned(), ]) } fn load_index(repository: &gix::Repository) -> Result { if repository.index_path().exists() { return repository.open_index().map_err(invalid); } match repository.head_commit() { Ok(commit) => { let tree = commit.tree_id().map_err(invalid)?; repository.index_from_tree(&tree).map_err(invalid) } Err(_) => Ok(gix::index::File::from_state( gix::index::State::new(repository.object_hash()), repository.index_path(), )), } } fn tree_from_index( repository: &gix::Repository, index: &gix::index::File, ) -> Result { let mut editor = repository .edit_tree(repository.empty_tree().id) .map_err(invalid)?; for entry in index.entries() { let mode = entry .mode .to_tree_entry_mode() .ok_or_else(|| GitError::InvalidRepository("invalid index entry mode".to_owned()))?; editor .upsert(entry.path(index), mode.kind(), entry.id) .map_err(invalid)?; } Ok(editor.write().map_err(invalid)?.detach()) } fn index_map(index: &gix::index::File) -> Result, GitError> { index .entries() .iter() .map(|entry| { let path = entry.path(index).to_str().map_err(|_| { GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned()) })?; Ok((path.to_owned(), entry.id)) }) .collect() } fn tree_map(tree: gix::Tree<'_>) -> Result, GitError> { let mut output = BTreeMap::new(); let mut recorder = gix::traverse::tree::Recorder::default(); tree.traverse() .breadthfirst(&mut recorder) .map_err(invalid)?; for record in recorder.records { if record.mode.is_blob() { let path = record.filepath.to_str().map_err(|_| { GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned()) })?; output.insert(path.to_owned(), record.oid); } else if !record.mode.is_tree() { return Err(GitError::UnsafeWorktreeObject { path: PathBuf::from(record.filepath.to_str_lossy().as_ref()), }); } } Ok(output) } fn tree_map_by_id( repository: &gix::Repository, tree: Option, ) -> Result, GitError> { match tree { Some(tree) => tree_map(repository.find_object(tree).map_err(invalid)?.into_tree()), None => Ok(BTreeMap::new()), } } fn apply_tree_to_worktree( repository: &gix::Repository, root: &Path, old_tree: Option, new_tree: gix::hash::ObjectId, ) -> Result<(), GitError> { let old = tree_map_by_id(repository, old_tree)?; let new = tree_map_by_id(repository, Some(new_tree))?; let changed = old .keys() .chain(new.keys()) .filter(|path| old.get(*path) != new.get(*path)) .cloned() .collect::>(); let mut replacement = BTreeMap::new(); let mut backup = BTreeMap::new(); for path in &changed { let relative = validate_relative(Path::new(path))?; let absolute = root.join(&relative); backup.insert( path.clone(), match fs::symlink_metadata(&absolute) { Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { Some(fs::read(&absolute).map_err(|_| io("read", &relative))?) } Ok(_) => return Err(GitError::UnsafeWorktreeObject { path: relative }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(_) => return Err(io("inspect", &relative)), }, ); if let Some(id) = new.get(path) { let bytes = repository .find_object(*id) .map_err(invalid)? .into_blob() .data .clone(); if bytes.len() as u64 > MAX_FILE_BYTES { return Err(GitError::FileTooLarge { path: relative }); } replacement.insert(path.clone(), bytes); } } let apply = (|| { for path in &changed { let relative = Path::new(path); match replacement.get(path) { Some(bytes) => atomic_write_worktree(root, relative, bytes)?, None => { let absolute = root.join(relative); match fs::remove_file(&absolute) { Ok(()) => cleanup_worktree_parents(root, relative), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err(io("remove", relative)), } } } } Ok(()) })(); if let Err(error) = apply { for (path, contents) in backup { match contents { Some(contents) => { let _ = atomic_write_worktree(root, Path::new(&path), &contents); } None => { let _ = fs::remove_file(root.join(&path)); } } } return Err(error); } Ok(()) } fn atomic_write_worktree(root: &Path, relative: &Path, contents: &[u8]) -> Result<(), GitError> { use std::io::Write as _; let relative = validate_relative(relative)?; let parent = relative.parent().unwrap_or(Path::new("")); ensure_worktree_directory(root, parent)?; let absolute = root.join(&relative); let file_name = relative.file_name().ok_or_else(|| GitError::InvalidPath { path: relative.clone(), })?; let mut temporary = None; for attempt in 0..128_u8 { let name = format!( ".{}.ironstorage-{}-{attempt}", file_name.to_string_lossy(), std::process::id() ); let path = root.join(parent).join(name); match fs::OpenOptions::new() .create_new(true) .write(true) .open(&path) { Ok(file) => { temporary = Some((path, file)); break; } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(_) => return Err(io("create temporary file for", &relative)), } } let (temporary_path, mut file) = temporary.ok_or_else(|| io("create temporary file for", &relative))?; let result = (|| { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; file.set_permissions(fs::Permissions::from_mode(0o600)) .map_err(|_| io("set permissions on", &relative))?; } file.write_all(contents) .map_err(|_| io("write", &relative))?; file.flush().map_err(|_| io("flush", &relative))?; file.sync_all().map_err(|_| io("synchronize", &relative))?; drop(file); fs::rename(&temporary_path, &absolute).map_err(|_| io("replace", &relative))?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(temporary_path); } result } fn ensure_worktree_directory(root: &Path, relative: &Path) -> Result<(), GitError> { let mut current = root.to_owned(); for component in relative.components() { let Component::Normal(name) = component else { return Err(GitError::InvalidPath { path: relative.to_owned(), }); }; current.push(name); match fs::symlink_metadata(¤t) { Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} Ok(_) => { return Err(GitError::UnsafeWorktreeObject { path: current.strip_prefix(root).unwrap_or(¤t).to_owned(), }); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { fs::create_dir(¤t).map_err(|_| io("create directory", relative))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(¤t, fs::Permissions::from_mode(0o700)) .map_err(|_| io("set directory permissions", relative))?; } } Err(_) => return Err(io("inspect directory", relative)), } } Ok(()) } fn cleanup_worktree_parents(root: &Path, relative: &Path) { let mut current = root.join(relative).parent().map(Path::to_owned); while let Some(directory) = current { if directory == root { break; } if fs::remove_dir(&directory).is_err() { break; } current = directory.parent().map(Path::to_owned); } } struct ReceivePackAdvertisement { refs: BTreeMap, capabilities: BTreeSet, } fn parse_receive_pack_advertisement(input: &[u8]) -> Result { let packets = decode_pkt_lines(input)?; let mut refs = BTreeMap::new(); let mut capabilities = BTreeSet::new(); let mut first_ref = true; for packet in packets.into_iter().flatten() { let line = packet.strip_suffix(b"\n").unwrap_or(&packet); if line.starts_with(b"# service=") || line.is_empty() { continue; } let (reference, caps) = if first_ref { match line.split_once_str(b"\0") { Some((reference, caps)) => (reference, Some(caps)), None => (line, None), } } else { (line, None) }; first_ref = false; let Some(space) = reference.find_byte(b' ') else { return Err(GitError::InvalidRepository( "malformed receive-pack advertisement".to_owned(), )); }; let id = &reference[..space]; let name = &reference[space + 1..]; if id == b"0000000000000000000000000000000000000000" && name == b"capabilities^{}" { // Empty repositories advertise capabilities in a pseudo-ref. } else { let id = gix::hash::ObjectId::from_hex(id).map_err(invalid)?; let name = std::str::from_utf8(name).map_err(|_| invalid("non-UTF-8 reference name"))?; if !name.ends_with("^{}") { refs.insert(name.to_owned(), id); } } if let Some(caps) = caps { capabilities.extend( caps.split(|byte| *byte == b' ') .filter(|capability| !capability.is_empty()) .map(|capability| { String::from_utf8_lossy(capability) .split('=') .next() .unwrap_or_default() .to_owned() }), ); } } if capabilities.is_empty() { return Err(GitError::InvalidRepository( "receive-pack advertisement has no capabilities".to_owned(), )); } Ok(ReceivePackAdvertisement { refs, capabilities }) } fn parse_receive_pack_result(input: &[u8], reference: &str) -> Result<(), GitError> { let packets = decode_pkt_lines(input) .map_err(|_| unknown_push_outcome("malformed receive-pack status"))?; let mut unpacked = false; let mut updated = false; for packet in packets.into_iter().flatten() { let line = packet.strip_prefix(b"\x01").unwrap_or(&packet); for line in line .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) { if line == b"unpack ok" { unpacked = true; } else if let Some(reason) = line.strip_prefix(b"unpack ") { return Err(GitError::RemoteUnpackFailed { diagnostic: protocol_diagnostic(reason), }); } else if line == format!("ok {reference}").as_bytes() { updated = true; } else if let Some(reason) = line.strip_prefix(format!("ng {reference} ").as_bytes()) { let reason = String::from_utf8_lossy(reason); return if reason.to_ascii_lowercase().contains("non-fast-forward") { Err(GitError::NonFastForward) } else { Err(GitError::RemoteRefRejected { diagnostic: protocol_diagnostic(reason.as_bytes()), }) }; } } } if !unpacked || !updated { return Err(unknown_push_outcome( "receive-pack response omitted update status", )); } Ok(()) } fn decode_pkt_lines(input: &[u8]) -> Result>>, GitError> { if input.len() > 128 * 1024 * 1024 { return Err(GitError::InvalidRepository( "Git protocol response is too large".to_owned(), )); } let mut cursor = 0; let mut packets = Vec::new(); while cursor < input.len() { if input.len() - cursor < 4 { return Err(GitError::InvalidRepository( "truncated Git packet line".to_owned(), )); } let length = std::str::from_utf8(&input[cursor..cursor + 4]) .ok() .and_then(|value| usize::from_str_radix(value, 16).ok()) .ok_or_else(|| GitError::InvalidRepository("invalid Git packet length".to_owned()))?; cursor += 4; if length == 0 || length == 1 || length == 2 { packets.push(None); continue; } if !(4..=65_520).contains(&length) || cursor + length - 4 > input.len() { return Err(GitError::InvalidRepository( "invalid Git packet line".to_owned(), )); } let end = cursor + length - 4; packets.push(Some(input[cursor..end].to_vec())); cursor = end; if packets.len() > 1_000_000 { return Err(GitError::InvalidRepository( "too many Git packet lines".to_owned(), )); } } Ok(packets) } fn encode_pkt_line(data: &[u8]) -> Result, GitError> { let length = data .len() .checked_add(4) .filter(|length| *length <= 65_520) .ok_or_else(|| GitError::InvalidRepository("Git packet line is too large".to_owned()))?; let mut output = format!("{length:04x}").into_bytes(); output.extend_from_slice(data); Ok(output) } fn build_pack( repository: &gix::Repository, tip: gix::hash::ObjectId, remote_tip: Option, ) -> Result, GitError> { let excluded = match remote_tip { Some(id) => reachable_objects(repository, id, &BTreeSet::new())?, None => BTreeSet::new(), }; let objects = reachable_objects(repository, tip, &excluded)?; let count = u32::try_from(objects.len()).map_err(|_| invalid("too many objects for a Git pack"))?; let mut pack = Vec::new(); pack.extend_from_slice(b"PACK"); pack.extend_from_slice(&2_u32.to_be_bytes()); pack.extend_from_slice(&count.to_be_bytes()); for id in objects { let object = repository.find_object(id).map_err(invalid)?; encode_pack_object_header(&mut pack, object.kind, object.data.len())?; let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); use std::io::Write as _; encoder.write_all(&object.data).map_err(invalid)?; pack.extend_from_slice(&encoder.finish().map_err(invalid)?); } let checksum = Sha1::digest(&pack); pack.extend_from_slice(&checksum); Ok(pack) } fn reachable_objects( repository: &gix::Repository, tip: gix::hash::ObjectId, excluded: &BTreeSet, ) -> Result, GitError> { let mut pending = vec![tip]; let mut found = BTreeSet::new(); while let Some(id) = pending.pop() { if excluded.contains(&id) || !found.insert(id) { continue; } let object = repository.find_object(id).map_err(invalid)?; match object.kind { gix::objs::Kind::Commit => { let commit = object.into_commit(); pending.push(commit.tree_id().map_err(invalid)?.detach()); pending.extend(commit.parent_ids().map(|id| id.detach())); } gix::objs::Kind::Tree => { pending.extend( object .into_tree() .decode() .map_err(invalid)? .entries .into_iter() .map(|entry| entry.oid.to_owned()), ); } gix::objs::Kind::Tag => { pending.push(object.into_tag().target_id().map_err(invalid)?.detach()) } gix::objs::Kind::Blob => {} } if found.len() > 10_000_000 { return Err(GitError::InvalidRepository( "repository has too many reachable objects".to_owned(), )); } } Ok(found) } fn encode_pack_object_header( output: &mut Vec, kind: gix::objs::Kind, size: usize, ) -> Result<(), GitError> { let object_type = match kind { gix::objs::Kind::Commit => 1_u8, gix::objs::Kind::Tree => 2, gix::objs::Kind::Blob => 3, gix::objs::Kind::Tag => 4, }; let mut remaining = u64::try_from(size).map_err(|_| invalid("Git object is too large"))?; let mut byte = (object_type << 4) | (remaining as u8 & 0x0f); remaining >>= 4; if remaining != 0 { byte |= 0x80; } output.push(byte); while remaining != 0 { let mut byte = remaining as u8 & 0x7f; remaining >>= 7; if remaining != 0 { byte |= 0x80; } output.push(byte); } Ok(()) } fn compare_maps( before: &BTreeMap, after: &BTreeMap, ) -> Vec { before .keys() .chain(after.keys()) .collect::>() .into_iter() .filter_map(|path| { let kind = match (before.get(path), after.get(path)) { (None, Some(_)) => GitChangeKind::Added, (Some(_), None) => GitChangeKind::Deleted, (Some(old), Some(new)) if old != new => GitChangeKind::Modified, _ => return None, }; Some(GitChange { path: PathBuf::from(path), kind, }) }) .collect() } fn collect_worktree( root: &Path, relative: &Path, output: &mut BTreeMap>, ) -> Result<(), GitError> { let absolute = root.join(relative); let mut entries = fs::read_dir(&absolute) .map_err(|_| io("read directory", relative))? .collect::, _>>() .map_err(|_| io("read directory", relative))?; entries.sort_by_key(std::fs::DirEntry::file_name); for entry in entries { if relative.as_os_str().is_empty() && entry.file_name() == ".git" { continue; } let path = relative.join(entry.file_name()); let metadata = fs::symlink_metadata(entry.path()).map_err(|_| io("inspect", &path))?; if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { return Err(GitError::UnsafeWorktreeObject { path }); } if metadata.is_dir() { if entry.path().join(".git").exists() { continue; } collect_worktree(root, &path, output)?; } else { if metadata.len() > MAX_FILE_BYTES { return Err(GitError::FileTooLarge { path }); } let name = path_to_git(&path)?; let name = std::str::from_utf8(&name) .map_err(|_| GitError::InvalidPath { path: path.clone() })? .to_owned(); output.insert(name, fs::read(entry.path()).map_err(|_| io("read", &path))?); } } Ok(()) } fn validate_relative(path: &Path) -> Result { if path.as_os_str().is_empty() || path.is_absolute() { return Err(GitError::InvalidPath { path: path.to_owned(), }); } let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::Normal(name) if name != ".git" => normalized.push(name), _ => { return Err(GitError::InvalidPath { path: path.to_owned(), }); } } } Ok(normalized) } fn validate_remote_name(name: &str) -> Result<(), GitError> { if name.is_empty() || name.starts_with('-') || name.contains(['\0', '\n', '\r', ' ', '\\', '~', '^', ':', '?', '*', '[']) || name.contains("..") || name.contains("@{") || name.ends_with('.') || name.ends_with('/') || name.starts_with('/') || name .split('/') .any(|part| part.is_empty() || part.ends_with(".lock")) { return Err(GitError::InvalidRemoteName); } Ok(()) } fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> { let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?; if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err(GitError::UnsafeWorktreeObject { path: parent.to_owned(), }); } Ok(()) } fn private_temporary_directory(parent: &Path, purpose: &str) -> Result { (0..128_u8) .find_map(|attempt| { let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::()); let candidate = parent.join(name); match fs::create_dir(&candidate) { Ok(()) => { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700)) .is_err() { let _ = fs::remove_dir(&candidate); return Some(Err(io("secure private Git directory", parent))); } } Some(Ok(candidate)) } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, Err(_) => Some(Err(io("create private Git directory", parent))), } }) .transpose()? .ok_or_else(|| io("create private Git directory", parent)) } fn require_https_remote( remote: &GitRemote, ) -> Result<(&url::Url, &ServerId, &ApplicationId), GitError> { let url = remote .endpoint() .as_https() .ok_or(GitError::UnsupportedRemoteTransport { transport: RemoteTransport::Ssh, })?; let (server_id, application_id) = remote .https_credentials() .ok_or(GitError::ForbiddenRemoteUrl)?; Ok((url, server_id, application_id)) } fn same_remote_endpoint(actual: &str, configured: &str) -> Result { let actual = RemoteEndpoint::parse(actual).map_err(|_| GitError::ForbiddenRemoteUrl)?; let configured = RemoteEndpoint::parse(configured).map_err(|_| GitError::ForbiddenRemoteUrl)?; Ok(actual == configured) } fn ensure_remote_transport_available(endpoint: &RemoteEndpoint) -> Result<(), GitError> { if endpoint.transport() == RemoteTransport::Ssh && !cfg!(feature = "ssh") { return Err(GitError::UnsupportedRemoteTransport { transport: RemoteTransport::Ssh, }); } Ok(()) } fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool { expected.scheme() == "https" && requested.scheme() == "https" && expected.host_str() == requested.host_str() && expected.port_or_known_default() == requested.port_or_known_default() } fn validate_config_key(key: &str) -> Result<(), GitError> { let mut parts = key.split('.'); let section = parts.next().unwrap_or_default(); let rest = parts.collect::>(); if section.is_empty() || rest.is_empty() || key.contains(['\0', '\n', '\r', ' ', '=']) || key.starts_with('.') || key.ends_with('.') || rest.iter().any(|part| part.is_empty()) { return Err(GitError::InvalidRepository( "invalid Git configuration key".to_owned(), )); } // These keys can make otherwise innocent library operations execute a // program or read configuration outside the repository. They are never // meaningful to an IronStorage frontend. let lower = key.to_ascii_lowercase(); let section = lower.split('.').next().unwrap_or_default(); if lower == "core.hookspath" || lower == "core.sshcommand" || lower == "core.gitproxy" || lower == "core.fsmonitor" || lower.ends_with(".pushurl") || matches!( section, "include" | "includeif" | "credential" | "filter" | "diff" | "merge" | "protocol" | "url" ) { return Err(GitError::InvalidRepository( "unsafe Git configuration key".to_owned(), )); } Ok(()) } fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitError> { for section in config.sections() { let name = section.header().name().to_str_lossy().to_ascii_lowercase(); if matches!( name.as_str(), "include" | "includeif" | "credential" | "filter" | "merge" | "protocol" | "url" ) { return Err(GitError::InvalidRepository(format!( "unsafe Git configuration section: {name}" ))); } } // Upstream `pass git init` installs diff.gpg.binary and // diff.gpg.textconv. IronStorage parses blobs and renders diffs itself, so // these passive settings are preserved but never evaluated or executed. for key in [ "core.hooksPath", "core.sshCommand", "core.gitProxy", "core.fsmonitor", ] { if config.raw_value(key).is_ok() { return Err(GitError::InvalidRepository(format!( "unsafe Git configuration key: {key}" ))); } } if let Some(remotes) = config.sections_by_name("remote") { for remote in remotes { let name = remote .header() .subsection_name() .ok_or(GitError::InvalidRemoteName)? .to_str() .map_err(|_| GitError::InvalidRemoteName)?; validate_remote_name(name)?; if config .raw_value_by("remote", Some(name.into()), "pushurl") .is_ok() { return Err(GitError::InvalidRepository( "separate Git push URLs are unsupported".to_owned(), )); } if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") { RemoteEndpoint::parse(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?) .map_err(|_| GitError::ForbiddenRemoteUrl)?; } } } Ok(()) } fn load_local_config(repository: &gix::Repository) -> Result { gix_config::File::from_path_no_includes( repository.git_dir().join("config"), gix_config::Source::Local, ) .map_err(invalid) } fn write_local_config( repository: &gix::Repository, config: &gix_config::File, ) -> Result<(), GitError> { use std::io::Write as _; let path = repository.git_dir().join("config"); let lock_path = repository.git_dir().join("config.lock"); let mut lock = fs::OpenOptions::new() .write(true) .create_new(true) .open(&lock_path) .map_err(|_| io("lock", &lock_path))?; let result = (|| { config.write_to(&mut lock).map_err(invalid)?; lock.flush().map_err(|_| io("flush", &lock_path))?; lock.sync_all().map_err(|_| io("synchronize", &lock_path))?; fs::rename(&lock_path, &path).map_err(|_| io("replace", &path))?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&lock_path); } result } fn path_to_git(path: &Path) -> Result, GitError> { let normalized = validate_relative(path)?; let text = normalized.to_str().ok_or_else(|| GitError::InvalidPath { path: path.to_owned(), })?; Ok(text.replace(std::path::MAIN_SEPARATOR, "/").into_bytes()) } fn matches_path(candidate: &[u8], selected: &[u8]) -> bool { candidate == selected || candidate .strip_prefix(selected) .is_some_and(|suffix| suffix.starts_with(b"/")) } fn validate_commit_message(message: &str) -> Result<(), GitError> { if message.trim().is_empty() || message.contains('\0') { return Err(GitError::InvalidRepository( "commit messages must not be empty".to_owned(), )); } Ok(()) } fn restore_index(mut index: gix::index::File) -> Result<(), GitError> { index.write(Default::default()).map_err(invalid) } fn invalid(error: impl fmt::Display) -> GitError { GitError::InvalidRepository(error.to_string()) } fn apply_in_memory_identity( repository: &mut gix::Repository, identity: &GitIdentity, ) -> Result<(), GitError> { let mut config = repository.config_snapshot_mut(); config .set_raw_value("user.name", identity.name()) .map_err(invalid)?; config .set_raw_value("user.email", identity.email()) .map_err(invalid)?; drop(config); if repository.committer().is_none() { return Err(GitError::InvalidIdentity); } Ok(()) } fn io(operation: &'static str, path: &Path) -> GitError { GitError::Io { operation, path: path.to_owned(), } } #[cfg(all(test, feature = "ssh"))] mod ssh_tests; #[cfg(all(test, feature = "ssh"))] mod ssh_push_tests; #[cfg(test)] mod tests { use std::{error::Error, fmt, io}; use super::{ GitError, GitIdentity, GitOperationControl, GitRemoteCredentialOverride, GitRepository, SshPassphraseProvider, classify_http_status, classify_structured_https_error, generic_https_error, map_https_fetch_error, }; use crate::{ config::SshFingerprint, repository::{Repository, SecretBytes}, }; struct Passphrases; #[derive(Debug)] struct Diagnostic(String); impl fmt::Display for Diagnostic { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } impl Error for Diagnostic {} impl SshPassphraseProvider for Passphrases { fn ssh_key_passphrase( &self, _fingerprint: &SshFingerprint, ) -> Result { Ok(SecretBytes::new(b"stored".to_vec())) } } #[test] fn embedded_identity_survives_local_config_updates() { let temporary = tempfile::tempdir().expect("temporary repository"); let store = Repository::open(temporary.path()).expect("store"); let mut repository = GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git"); repository .add_remote("origin", "https://example.test/password-store.git") .expect("add remote"); assert!(repository.repository.committer().is_some()); } #[test] fn https_failures_use_only_structured_status_and_sources() { assert_eq!( classify_http_status(reqwest::StatusCode::UNAUTHORIZED), Some(GitError::AuthenticationFailed) ); assert_eq!( classify_http_status(reqwest::StatusCode::FORBIDDEN), Some(GitError::AuthenticationFailed) ); assert_eq!( classify_structured_https_error(&io::Error::from(io::ErrorKind::ConnectionRefused)), Some(GitError::NetworkUnavailable) ); assert_eq!( classify_structured_https_error(&io::Error::from(io::ErrorKind::TimedOut)), Some(GitError::NetworkUnavailable) ); assert_eq!( classify_structured_https_error(&rustls::Error::General("opaque".to_owned())), Some(GitError::TlsFailed) ); let rendered_only = Diagnostic( "401 403 authentication credential certificate tls network connect dns".to_owned(), ); assert_eq!(classify_structured_https_error(&rendered_only), None); assert!(matches!( generic_https_error(&rendered_only), GitError::InvalidRepository(_) )); let local_permission = gix::remote::fetch::Error::RemovePackKeepFile { path: "pack.keep".into(), source: io::Error::from(io::ErrorKind::PermissionDenied), }; assert!(matches!( map_https_fetch_error(local_permission, &GitOperationControl::default()), GitError::InvalidRepository(_) )); } #[test] fn generic_https_diagnostic_is_bounded() { let error = generic_https_error(&Diagnostic("x".repeat(32 * 1024))); let GitError::InvalidRepository(diagnostic) = error else { panic!("unstructured HTTPS error must stay generic"); }; assert!(diagnostic.len() <= 8 * 1024); } #[test] fn one_ssh_passphrase_override_is_bound_to_its_fingerprint() { let selected = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU") .expect("fingerprint"); let other = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") .expect("fingerprint"); let supplied = SecretBytes::new(b"prompted".to_vec()); let credentials = GitRemoteCredentialOverride::with_ssh_passphrase(&Passphrases, &selected, &supplied); assert_eq!( credentials .ssh_key_passphrase(&selected) .expect("override") .expose(), b"prompted" ); assert_eq!( credentials .ssh_key_passphrase(&other) .expect("stored fallback") .expose(), b"stored" ); } #[cfg(feature = "ssh")] #[test] fn host_confirmation_is_bound_to_the_configured_endpoint() { let temporary = tempfile::tempdir().expect("temporary directory"); let known_hosts = temporary.path().join("known_hosts"); let fingerprint = SshFingerprint::parse("SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU") .expect("fingerprint"); let authentication = crate::config::SshRemoteAuthentication::agent( fingerprint.clone(), None, known_hosts.clone(), ) .expect("authentication"); let remote = crate::config::GitRemote::ssh_with_authentication( "origin", "ssh://git@example.test:2222/team/store.git", authentication, ) .expect("remote"); let observed = super::SshHostKey::new( "other.example.test".to_owned(), 2222, "ssh-ed25519".to_owned(), fingerprint, "invalid-key".to_owned(), ); assert_eq!( super::confirm_ssh_host(&remote, &observed), Err(GitError::SshProtocolFailed) ); assert!(!known_hosts.exists()); } }