4559 lines
151 KiB
Rust
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(¤t) {
|
|
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
|
|
Ok(_) => {
|
|
return Err(GitError::UnsafeWorktreeObject {
|
|
path: current.strip_prefix(root).unwrap_or(¤t).to_owned(),
|
|
});
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
fs::create_dir(¤t).map_err(|_| io("create directory", relative))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
fs::set_permissions(¤t, fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| io("set directory permissions", relative))?;
|
|
}
|
|
}
|
|
Err(_) => return Err(io("inspect directory", relative)),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cleanup_worktree_parents(root: &Path, relative: &Path) {
|
|
let mut current = root.join(relative).parent().map(Path::to_owned);
|
|
while let Some(directory) = current {
|
|
if directory == root {
|
|
break;
|
|
}
|
|
if fs::remove_dir(&directory).is_err() {
|
|
break;
|
|
}
|
|
current = directory.parent().map(Path::to_owned);
|
|
}
|
|
}
|
|
|
|
struct ReceivePackAdvertisement {
|
|
refs: BTreeMap<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());
|
|
}
|
|
}
|