Files
IronStorage/crates/storage/src/git.rs
T
hugo c2dd826920
Dependency security audit / rustsec (push) Failing after 3s
Implement audited findings
2026-08-27 15:34:31 +02:00

4559 lines
151 KiB
Rust

//! 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<String>, email: impl Into<String>) -> Result<Self, GitError> {
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<GitChange>,
unstaged: Vec<GitChange>,
}
#[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<GitRemoteStatus>,
recent: Vec<GitLogEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitCommitActivity {
commit: GitLogEntry,
changes: Vec<GitChange>,
}
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<GitCommitActivity>,
outgoing: Vec<GitCommitActivity>,
}
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<AtomicBool>,
progress: Arc<dyn Fn(GitProgressPhase) + Send + Sync>,
}
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<String>,
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<Vec<u8>>,
new: Option<Vec<u8>>,
}
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<SshHostKey>,
},
ChangedSshHostKey {
host_key: Box<SshHostKey>,
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<GitConflict>,
},
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<GitRepository>),
None(NoGitEntryCommitter),
}
/// Storage-owned selection of pass-compatible automatic Git commits for a
/// recipient policy change.
pub enum AutomaticPolicyCommitter {
Git(Box<GitRepository>),
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<GitRepository>),
None(crate::mutation::NoGitTreeCommitter),
}
impl AutomaticTreeCommitter {
pub fn for_source(
repository: &Repository,
source: &str,
identity: GitIdentity,
) -> Result<Self, GitError> {
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<Self, GitError> {
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<Self, GitError> {
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<String>, mut password: Vec<u8>) -> Result<Self, GitError> {
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<GitCredential, GitError>;
fn credential_for(&self, remote: &GitRemote) -> Result<GitCredential, GitError> {
let (_, server, application) = require_https_remote(remote)?;
self.credential(server, application)
}
}
pub trait SshPassphraseProvider {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError>;
}
pub trait GitRemoteCredentialProvider: GitCredentialProvider + SshPassphraseProvider {}
impl<T: GitCredentialProvider + SshPassphraseProvider> 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<P: GitCredentialProvider> GitCredentialProvider for GitRemoteCredentialOverride<'_, P> {
fn credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<GitCredential, GitError> {
self.provider.credential(server, application)
}
fn credential_for(&self, remote: &GitRemote) -> Result<GitCredential, GitError> {
self.provider.credential_for(remote)
}
}
impl<P: SshPassphraseProvider> SshPassphraseProvider for GitRemoteCredentialOverride<'_, P> {
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
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<Vec<u8>, GitError>;
fn receive_pack(
&self,
url: &url::Url,
credential: &GitCredential,
request: Vec<u8>,
) -> Result<Vec<u8>, GitError>;
fn advertise_receive_pack_controlled(
&self,
url: &url::Url,
credential: &GitCredential,
control: &GitOperationControl,
) -> Result<Vec<u8>, 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<u8>,
control: &GitOperationControl,
) -> Result<Vec<u8>, 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<bool, GitError>;
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
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<bool, GitError> {
repository.fetch_embedded(configured, credentials)
}
fn fetch_controlled(
&self,
repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
repository.fetch_embedded_controlled(configured, credentials, control)
}
}
#[derive(Default)]
pub struct ReqwestGitTransport;
impl ReqwestGitTransport {
fn client() -> Result<reqwest::blocking::Client, GitError> {
reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(invalid)
}
fn endpoint(url: &url::Url, suffix: &str) -> Result<url::Url, GitError> {
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<reqwest::blocking::RequestBuilder, GitError> {
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<Vec<u8>, 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<Vec<u8>, 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<u8>,
) -> Result<Vec<u8>, 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<crate::ssh::SshSession>,
command: Option<crate::ssh::SshRawGitCommand>,
},
}
impl ReceivePackClient<'_> {
fn advertisement(&mut self, control: &GitOperationControl) -> Result<Vec<u8>, 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<u8>,
control: &GitOperationControl,
) -> Result<Vec<u8>, 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<u8>,
control: &GitOperationControl,
update_may_apply: bool,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.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<GitError> {
matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
)
.then_some(GitError::AuthenticationFailed)
}
fn classify_structured_https_error(error: &(dyn Error + 'static)) -> Option<GitError> {
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::<reqwest::Error>() {
if error.status().and_then(classify_http_status).is_some() {
return Some(GitError::AuthenticationFailed);
}
network |= error.is_timeout() || error.is_connect();
}
if error.is::<rustls::Error>() {
tls = true;
}
if let Some(error) = error.downcast_ref::<std::io::Error>() {
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<String>,
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<Vec<String>, 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<Vec<String>, 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, GitError> {
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, GitError> {
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, GitError> {
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<Self, GitError> {
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<Vec<String>, 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::<Result<Vec<_>, 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<Self, GitError> {
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::<Vec<_>>();
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, GitError> {
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<Self, GitError> {
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<Self, GitError> {
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<String> {
self.repository
.remote_names()
.into_iter()
.map(|name| name.to_string())
.collect()
}
pub fn remote_url(&self, name: &str) -> Result<String, GitError> {
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<Option<String>, 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::<Vec<_>>();
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<FetchOutcome, GitError> {
self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
}
pub fn fetch_controlled(
&self,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<FetchOutcome, GitError> {
self.fetch_with_transport_controlled(
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn fetch_with_transport(
&self,
configured: &GitRemote,
credentials: &impl GitRemoteCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<FetchOutcome, GitError> {
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<FetchOutcome, GitError> {
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<bool, GitError> {
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<bool, GitError> {
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<bool, GitError> {
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<bool, GitError> {
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<PullOutcome, GitError> {
self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
}
pub fn pull_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<PullOutcome, GitError> {
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<PullOutcome, GitError> {
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<PullOutcome, GitError> {
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<PullOutcome, GitError> {
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::<Vec<_>>();
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<PushOutcome, GitError> {
self.push_controlled(
configured,
branch,
credentials,
&GitOperationControl::default(),
)
}
pub fn push_controlled(
&self,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<PushOutcome, GitError> {
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<PushOutcome, GitError> {
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<PushOutcome, GitError> {
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<PushContext, GitError> {
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<PushOutcome, GitError> {
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<GitSnapshot, GitError> {
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<GitDivergence, GitError> {
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<gix::hash::ObjectId>,
limit: usize,
) -> Result<Vec<GitCommitActivity>, 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<GitCommitActivity, GitError> {
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<BTreeSet<gix::hash::ObjectId>, 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<PullOutcome, GitError> {
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::<BTreeMap<_, _>>();
let conflicts = outcome
.tree_merge
.conflicts
.iter()
.filter(|conflict| conflict.is_unresolved(unresolved))
.collect::<Vec<_>>();
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<String, GitError> {
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<gix::hash::ObjectId>,
) -> 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<gix::hash::ObjectId>,
message: impl Into<BString>,
) -> 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::<Result<Vec<_>, _>>()
.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<String, GitError> {
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<String, GitError> {
let status = self.status()?;
let paths = status
.staged()
.iter()
.chain(status.unstaged())
.map(|change| change.path().to_owned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
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<String, GitError> {
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::<Vec<_>>();
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<String, GitError> {
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<String, GitError> {
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<Option<String>, 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<Option<String>, 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::<Result<Vec<_>, _>>()?;
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::<BTreeSet<_>>();
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<GitStatus, GitError> {
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<usize>) -> Result<Vec<GitLogEntry>, 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<Vec<GitDiffEntry>, 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::<Result<Vec<_>, _>>()?;
let mut names = head
.keys()
.chain(worktree.keys())
.cloned()
.collect::<BTreeSet<_>>();
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<SecretBytes, GitError> {
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<Option<BTreeMap<String, gix::hash::ObjectId>>, 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<Option<BTreeMap<String, Vec<u8>>>, 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<BTreeMap<String, gix::hash::ObjectId>, 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<BTreeMap<String, Vec<u8>>, 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<SecretBytes, GitError> {
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<u8>, 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<GitLogEntry, GitError> {
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<gix::index::File, GitError> {
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<gix::hash::ObjectId, GitError> {
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<BTreeMap<String, gix::hash::ObjectId>, 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<BTreeMap<String, gix::hash::ObjectId>, 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<gix::hash::ObjectId>,
) -> Result<BTreeMap<String, gix::hash::ObjectId>, 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<gix::hash::ObjectId>,
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::<BTreeSet<_>>();
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(&current) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => {
return Err(GitError::UnsafeWorktreeObject {
path: current.strip_prefix(root).unwrap_or(&current).to_owned(),
});
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(&current).map_err(|_| io("create directory", relative))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&current, 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<String, gix::hash::ObjectId>,
capabilities: BTreeSet<String>,
}
fn parse_receive_pack_advertisement(input: &[u8]) -> Result<ReceivePackAdvertisement, GitError> {
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<Vec<Option<Vec<u8>>>, 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<Vec<u8>, 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<gix::hash::ObjectId>,
) -> Result<Vec<u8>, 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<gix::hash::ObjectId>,
) -> Result<BTreeSet<gix::hash::ObjectId>, 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<u8>,
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<String, gix::hash::ObjectId>,
after: &BTreeMap<String, gix::hash::ObjectId>,
) -> Vec<GitChange> {
before
.keys()
.chain(after.keys())
.collect::<BTreeSet<_>>()
.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<String, Vec<u8>>,
) -> Result<(), GitError> {
let absolute = root.join(relative);
let mut entries = fs::read_dir(&absolute)
.map_err(|_| io("read directory", relative))?
.collect::<Result<Vec<_>, _>>()
.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<PathBuf, GitError> {
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<PathBuf, GitError> {
(0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
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<bool, GitError> {
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::<Vec<_>>();
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, GitError> {
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<Vec<u8>, 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<SecretBytes, GitError> {
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());
}
}