Git-Native
view release on metacpan or search on metacpan
lib/Git/Native/Remote.pm view on Meta::CPAN
return Git::Native::Remote::Result->new(
updated => \@updated,
rejected => \@rejected,
);
}
# List the remote-side refs (requires connecting first). Returns an
# arrayref of names. Caller passes credentials cb so private remotes work.
sub list_refs {
my ( $self, %args ) = @_;
$self->_connect( GIT_DIRECTION_FETCH, $args{credentials} );
my @names;
eval {
check_rc Git::Libgit2::FFI::git_remote_ls(
\my $heads_arr, \my $count, $self->_handle,
);
# heads_arr is git_remote_head**: an array of $count pointers,
# each pointing to a git_remote_head whose .name (char*) lives at
# offset REMOTE_HEAD_NAME_OFFSET.
my $ffi = Git::Libgit2::FFI::ffi();
for ( my $i = 0; $i < $count; $i++ ) {
my $head_ptr = unpack 'J',
_peek_bytes( $heads_arr + $i * PTR_SIZE, PTR_SIZE );
my $name_ptr = unpack 'J',
_peek_bytes( $head_ptr + REMOTE_HEAD_NAME_OFFSET, PTR_SIZE );
my $name = $ffi->cast( 'opaque' => 'string', $name_ptr );
CORE::push @names, $name;
}
};
my $err = $@;
Git::Libgit2::FFI::git_remote_disconnect( $self->_handle );
die $err if $err;
return \@names;
}
sub _connect {
my ( $self, $direction, $cred_cb ) = @_;
# Build a callbacks struct on the stack-ish (Perl-owned buffer).
my $cb = "\0" x CALLBACKS_SIZE;
my ($cb_ptr) = scalar_to_buffer($cb);
check_rc Git::Libgit2::FFI::git_remote_init_callbacks(
$cb_ptr, GIT_REMOTE_CALLBACKS_VERSION,
);
my @keep = ( \$cb );
if ($cred_cb) {
my ( $thunk, $thunk_keep ) = _make_credential_thunk($cred_cb);
CORE::push @keep, $thunk_keep;
my $ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_credential_acquire_cb' => 'opaque', $thunk,
);
my $pkt = pack 'J', $ptr_val;
my ($pkt_p) = scalar_to_buffer($pkt);
memcpy( $cb_ptr + CALLBACKS_CRED_OFFSET, $pkt_p, 8 );
CORE::push @keep, \$pkt;
}
_install_certcheck( $cb_ptr, 0, \@keep );
check_rc Git::Libgit2::FFI::git_remote_connect(
$self->_handle, $direction, $cb_ptr, 0, 0,
);
# Hold keepalive on $self so it survives until the next call frees it.
$self->{_connect_keep} = \@keep;
return $self;
}
# Compute delete refspecs for `--prune`: for each `[+]src:dst` with `*`,
# list remote refs matching the dst pattern, and emit a delete for each
# one whose local counterpart no longer exists.
sub _compute_prune_deletes {
my ( $self, $refspecs, $cred_cb ) = @_;
my $remote_names = $self->list_refs( credentials => $cred_cb );
my %local;
$local{$_} = 1 for @{ $self->_owner->reference_names };
my @deletes;
my %seen;
# Walk *original* user refspecs to figure out the dst-pattern namespace.
# We can't recover the dst-pattern from already-expanded specs.
for my $rs (@$refspecs) {
my ( $force, $src, $dst ) = $rs =~ /\A(\+?)([^:]+):(.+)\z/;
next unless defined $src && $dst =~ /\*/;
# Map remote ref â expected local name using dstâsrc.
my $dst_re = quotemeta($dst); $dst_re =~ s/\\\*/(.*)/;
$dst_re = qr/\A${dst_re}\z/;
my $src_template = $src;
for my $rname (@$remote_names) {
my ($cap) = $rname =~ $dst_re;
next unless defined $cap;
my $expected_local = $src_template;
$expected_local =~ s/\*/$cap/;
next if $local{$expected_local};
next if $seen{$rname}++;
CORE::push @deletes, ":${rname}";
}
}
return @deletes;
}
# Read N bytes from a raw C address into a Perl scalar.
sub _peek_bytes {
my ( $addr, $len ) = @_;
my $buf = "\0" x $len;
my ($bp) = scalar_to_buffer($buf);
memcpy( $bp, $addr, $len );
return $buf;
}
# libgit2 git_remote_push does NOT expand wildcard refspecs (unlike CLI
# git). We do it here: for each `+?src:dst` refspec containing `*`,
# enumerate matching local refs and emit one explicit refspec per ref.
sub _expand_push_refspecs {
my ( $self, $refspecs ) = @_;
$refspecs //= [];
my @out;
for my $rs (@$refspecs) {
my ( $force, $src, $dst ) = $rs =~ /\A(\+?)([^:]+):(.+)\z/;
if ( !defined $src || ( index( $src, '*' ) < 0 && index( $dst, '*' ) < 0 ) ) {
CORE::push @out, $rs;
next;
}
my $src_re = quotemeta($src);
$src_re =~ s/\\\*/(.*)/;
$src_re = qr/\A${src_re}\z/;
my $names = $self->_owner->reference_names( glob => $src );
for my $name (@$names) {
my ($cap) = $name =~ $src_re;
next unless defined $cap;
my $expanded_dst = $dst;
$expanded_dst =~ s/\*/$cap/;
CORE::push @out, "${force}${name}:${expanded_dst}";
}
}
return \@out;
}
# ---------- internals ----------
# Build a git_strarray pointing into Perl-owned memory. Returns
# ($strarray_ptr, $keepalive_scalars_ref). Caller must hold
# $keepalive_scalars_ref alive across the C call.
sub _build_strarray {
my ($refspecs) = @_;
$refspecs //= [];
Carp::croak "_build_strarray: refspecs must be an arrayref"
if ref $refspecs ne 'ARRAY';
# Empty list â NULL strarray pointer, which libgit2 reads as
# "use configured refspecs from .git/config".
return ( 0, [] ) unless @$refspecs;
# Copy each string so we have stable storage we control.
my @copies = map { "$_" } @$refspecs;
my @ptrs;
for my $s (@copies) {
my ($p) = scalar_to_buffer($s);
CORE::push @ptrs, $p;
}
my $strings_buf = pack 'J*', @ptrs;
my ($strings_ptr) = scalar_to_buffer($strings_buf);
my $strarray = pack 'JJ', $strings_ptr, scalar(@copies);
my ($sa_ptr) = scalar_to_buffer($strarray);
# Keep refs to every buffer that owns memory referenced from $strarray.
return ( $sa_ptr, [ \@copies, \$strings_buf, \$strarray ] );
}
sub _build_fetch_options {
my ( $cred_cb, $prune, $update_tips_thunk ) = @_;
my $opts = "\0" x FETCH_OPTIONS_SIZE;
my ($opts_ptr) = scalar_to_buffer($opts);
check_rc Git::Libgit2::FFI::git_fetch_options_init(
$opts_ptr, GIT_FETCH_OPTIONS_VERSION,
);
my @keep = ( \$opts );
if ($cred_cb) {
my ( $cb_thunk, $cb_keep ) = _make_credential_thunk($cred_cb);
CORE::push @keep, $cb_keep;
# Write the closure's C pointer into callbacks.credentials.
my $cb_ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_credential_acquire_cb' => 'opaque', $cb_thunk,
);
my $cb_buf = pack 'J', $cb_ptr_val;
my ($cb_buf_ptr) = scalar_to_buffer($cb_buf);
memcpy( $opts_ptr + FETCH_OPTS_CALLBACKS_OFFSET + CALLBACKS_CRED_OFFSET,
$cb_buf_ptr, 8 );
CORE::push @keep, \$cb_buf;
}
_install_certcheck( $opts_ptr, FETCH_OPTS_CALLBACKS_OFFSET, \@keep );
if ($update_tips_thunk) {
my $ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_remote_update_tips_cb' => 'opaque', $update_tips_thunk,
);
my $buf = pack 'J', $ptr_val;
my ($bp) = scalar_to_buffer($buf);
memcpy( $opts_ptr + FETCH_OPTS_CALLBACKS_OFFSET
+ CALLBACKS_UPDATE_TIPS_OFFSET, $bp, 8 );
CORE::push @keep, \$buf;
}
if ( defined $prune ) {
my $val = $prune ? 1 : 2; # 1 = PRUNE, 2 = NO_PRUNE
my $pb = pack 'l', $val;
my ($pbp) = scalar_to_buffer($pb);
memcpy( $opts_ptr + FETCH_OPTS_PRUNE_OFFSET, $pbp, 4 );
CORE::push @keep, \$pb;
}
return ( $opts_ptr, \@keep );
}
sub _build_push_options {
my ( $cred_cb, $push_update_thunk ) = @_;
my $opts = "\0" x PUSH_OPTIONS_SIZE;
my ($opts_ptr) = scalar_to_buffer($opts);
check_rc Git::Libgit2::FFI::git_push_options_init(
$opts_ptr, GIT_PUSH_OPTIONS_VERSION,
);
my @keep = ( \$opts );
if ($cred_cb) {
my ( $cb_thunk, $cb_keep ) = _make_credential_thunk($cred_cb);
CORE::push @keep, $cb_keep;
my $cb_ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_credential_acquire_cb' => 'opaque', $cb_thunk,
);
my $cb_buf = pack 'J', $cb_ptr_val;
my ($cb_buf_ptr) = scalar_to_buffer($cb_buf);
memcpy( $opts_ptr + PUSH_OPTS_CALLBACKS_OFFSET + CALLBACKS_CRED_OFFSET,
$cb_buf_ptr, 8 );
CORE::push @keep, \$cb_buf;
}
_install_certcheck( $opts_ptr, PUSH_OPTS_CALLBACKS_OFFSET, \@keep );
if ($push_update_thunk) {
my $ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_push_update_reference_cb' => 'opaque', $push_update_thunk,
);
my $buf = pack 'J', $ptr_val;
my ($bp) = scalar_to_buffer($buf);
memcpy( $opts_ptr + PUSH_OPTS_CALLBACKS_OFFSET
+ CALLBACKS_PUSH_UPDATE_REF_OFFSET, $bp, 8 );
CORE::push @keep, \$buf;
}
return ( $opts_ptr, \@keep );
}
# Wrap a user coderef so it conforms to git_credential_acquire_cb.
# Returns ($closure, $keepalive). The closure must outlive the C call â
# the keepalive bundle is what the Remote method holds onto.
#
# NOTHING in here may die: the closure is called from libgit2's C frames,
# and a Perl exception unwinding across them is undefined behaviour. Every
# failure mode reports via warn and returns a negative rc, which libgit2
# propagates out of git_remote_fetch/push for check_rc to throw properly.
sub _make_credential_thunk {
my ($user_cb) = @_;
my $ffi = Git::Libgit2::FFI::ffi();
my $closure = $ffi->closure(sub {
my ( $out_ptr, $url, $username_from_url, $allowed_types, $payload ) = @_;
my $cred = eval {
$user_cb->(
url => $url,
username_from_url => $username_from_url,
allowed_types => $allowed_types,
);
};
if ($@) {
warn "credential callback died: $@";
return -1;
}
return GIT_PASSTHROUGH unless defined $cred;
unless ( Scalar::Util::blessed($cred)
&& $cred->isa('Git::Native::Credential') ) {
warn "credentials callback must return a Git::Native::Credential "
. "or undef, got " . _describe_value($cred) . "\n";
return -1;
}
# Disown the wrapper â libgit2 takes ownership on return 0.
my $cred_handle = $cred->_disown;
# *out_ptr = cred_handle (write 8 bytes of pointer to the address
# the caller gave us)
my $pkt = pack 'J', $cred_handle;
my ($pkt_p) = scalar_to_buffer($pkt);
memcpy( $out_ptr, $pkt_p, 8 );
return 0;
});
# `sticky` would survive process-lifetime; we only need until the C
# call returns, so just hand the closure to the caller's keepalive.
return ( $closure, [ \$closure ] );
}
# Name a value for a diagnostic without ever dying on it â a blessed object
# may carry an overloaded (and throwing) stringifier, so report its class
# instead of interpolating it. Only used from inside FFI closures, where a
# die is not survivable.
sub _describe_value {
my ($v) = @_;
if ( my $class = Scalar::Util::blessed($v) ) { return "a $class object" }
if ( my $type = ref $v ) { return "a $type reference" }
my $str = "$v";
$str = substr( $str, 0, 60 ) . '...' if length($str) > 63;
return "the non-reference value '$str'";
}
# Build the update_tips closure (git_remote_callbacks.update_tips).
# Records each accepted ref update into the caller's $updated arrayref.
#
# int cb(const char *refname, const git_oid *a, const git_oid *b, void *data)
#
# a is the old local tip; libgit2 passes a non-NULL pointer even when the
# ref didn't exist locally (the bytes are zero-filled â a zero SHA-1).
# We translate the all-zero "no previous ref" case to from => undef so
# callers can tell new-ref from same-oid updates without a magic constant.
# b is the new local tip, and is the all-zero oid when the ref was DELETED
# rather than moved â that is how a `prune => 1` fetch reports the refs it
# dropped (probed against a file:// remote). Same treatment: to => undef,
# which is also what the push side reports for a delete refspec, so
# `!defined $_->{to}` means "this ref is gone" on both operations.
#
# `reason` is always the empty string here: update_tips only fires for
# updates libgit2 accepted, and there is no server verdict on a fetch at all.
# It exists so a fetch entry and a push entry carry the identical key set â
# see Git::Native::Remote::Result.
#
# Returns 0 on success â returning non-zero aborts the fetch, which is what
# we want if libgit2 handed us something we cannot record (a NULL new tip),
# because the Result would otherwise lie by omission. Like every FFI closure
# here it must not let a die escape into libgit2's C frames, so the body runs
# under eval and reports via warn.
sub _make_update_tips_thunk {
my ($updated) = @_;
my $ffi = Git::Libgit2::FFI::ffi();
my $closure = $ffi->closure(sub {
my ( $refname, $a_ptr, $b_ptr, $payload ) = @_;
my $ok = eval {
die "update_tips callback got NULL b oid for ref '$refname'\n"
unless $b_ptr;
my $from = $a_ptr ? _oid_hex_if_nonzero($a_ptr) : undef;
my $to = _oid_hex_if_nonzero($b_ptr);
CORE::push @$updated, {
ref => $refname,
from => $from,
to => $to,
reason => '',
};
1;
};
lib/Git/Native/Remote.pm view on Meta::CPAN
CORE::push @$updated, {
ref => $refname,
from => undef,
to => $targets->{$refname},
reason => '',
};
}
else {
CORE::push @$rejected, { ref => $refname, reason => $status };
}
return 0;
});
return ( $closure, [ \$closure ] );
}
# Map each expanded push refspec's destination refname to the oid the push
# puts there, so a push update can report `to` the way a fetch update does.
# push_update_reference only hands us a per-ref verdict, but the source side
# of the refspec is a local ref we can just look up â no extra network.
#
# The value is undef for a delete refspec (`:refs/x`, which is what
# `prune => 1` emits) â matching the deleted-ref convention on the fetch
# side â and also undef for a source that is not a local reference (a raw
# oid, a shorthand, a ref that vanished between expansion and push).
sub _push_update_targets {
my ( $self, $refspecs ) = @_;
my %target;
for my $rs (@$refspecs) {
my ( $src, $dst );
if ( index( $rs, ':' ) >= 0 ) {
( $src, $dst ) = $rs =~ /\A\+?([^:]*):(.*)\z/;
}
else {
# A refspec without a colon pushes the source to the same name.
($src) = $rs =~ /\A\+?(.*)\z/;
$dst = $src;
}
next unless defined $dst && length $dst;
$target{$dst} = ( defined $src && length $src )
? $self->_local_oid_hex($src)
: undef;
}
return \%target;
}
# Hex oid a local refname points at, or undef if it does not resolve to
# one. Goes through resolve so a symbolic source (`HEAD:refs/heads/main`)
# reports the oid it points at rather than nothing. Never dies: an
# unresolvable push source is a missing `to`, not a failed push.
sub _local_oid_hex {
my ( $self, $refname ) = @_;
my $oid = eval { $self->_owner->reference($refname)->resolve->target };
return undef unless $oid;
return $oid->hex;
}
# ---------- host-key verification (certificate_check callback) ----------
# Write a certificate_check closure into a callbacks struct. $cb_base is the
# offset of the embedded git_remote_callbacks within $struct_ptr (0 for a
# bare callbacks struct, 8 for fetch/push options). Pushes keepalives.
#
# libgit2 1.5.x + libssh2 has NO built-in known_hosts checking (that landed in
# 1.7). Without a certificate_check callback the ssh transport rejects every
# host with GIT_ECERTIFICATE (-17) "invalid or unknown remote ssh hostkey".
# So we always install one and verify the hostkey against ~/.ssh/known_hosts
# ourselves, mirroring what the `git` CLI does via OpenSSH.
sub _install_certcheck {
my ( $struct_ptr, $cb_base, $keep ) = @_;
my ( $thunk, $thunk_keep ) = _make_certcheck_thunk();
CORE::push @$keep, @$thunk_keep;
my $ptr_val = Git::Libgit2::FFI::ffi->cast(
'git_transport_certificate_check_cb' => 'opaque', $thunk,
);
my $buf = pack 'J', $ptr_val;
my ($bp) = scalar_to_buffer($buf);
memcpy( $struct_ptr + $cb_base + CALLBACKS_CERTCHECK_OFFSET, $bp, 8 );
CORE::push @$keep, \$buf;
return;
}
# Build the certificate_check closure. Returns ($closure, $keepalive).
#
# int cb(git_cert *cert, int valid, const char *host, void *payload)
#
# Return 0 to accept, <0 to reject (libgit2 aborts the connection with that
# code). For TLS (git_cert_x509) we honour libgit2's own `valid` flag so HTTPS
# remotes keep their normal CA validation. For SSH (git_cert_hostkey) we verify
# against known_hosts unless GIT_NATIVE_SSH_INSECURE is set (accept-all).
sub _make_certcheck_thunk {
my $ffi = Git::Libgit2::FFI::ffi();
my $closure = $ffi->closure(sub {
my ( $cert_ptr, $valid, $host, $payload ) = @_;
my $ok = eval {
my $cert_type = unpack 'l', _peek_bytes( $cert_ptr, 4 );
return $valid ? 1 : 0 if $cert_type == GIT_CERT_X509;
if ( $cert_type == GIT_CERT_HOSTKEY_LIBSSH2 ) {
return 1 if $ENV{GIT_NATIVE_SSH_INSECURE};
return _verify_known_host( $cert_ptr, $host );
}
# Unknown cert kind â fall back to libgit2's own verdict.
return $valid ? 1 : 0;
};
if ($@) {
warn "Git::Native certificate check died: $@";
return -1;
}
return $ok ? 0 : -1;
});
return ( $closure, [ \$closure ] );
}
# Verify an ssh hostkey against known_hosts using the SHA256 (preferred) or
# SHA1 fingerprint libssh2 computed for the negotiated key. Returns 1 on a
# match, 0 otherwise (with an actionable warning).
sub _verify_known_host {
my ( $cert_ptr, $host ) = @_;
my $bits = unpack 'l', _peek_bytes( $cert_ptr + CERT_HOSTKEY_TYPE_OFFSET, 4 );
my ( $digest, $want );
if ( $bits & GIT_CERT_SSH_SHA256 ) {
$digest = 'sha256';
$want = _peek_bytes( $cert_ptr + CERT_HOSTKEY_SHA256_OFFSET, 32 );
}
elsif ( $bits & GIT_CERT_SSH_SHA1 ) {
$digest = 'sha1';
$want = _peek_bytes( $cert_ptr + CERT_HOSTKEY_SHA1_OFFSET, 20 );
}
else {
warn "Git::Native: ssh hostkey for '$host' offers no SHA1/SHA256 "
. "fingerprint to verify; rejecting\n";
return 0;
}
my ( $matched, $host_seen ) = _known_hosts_match( $host, $digest, $want );
return 1 if $matched;
if ($host_seen) {
warn "Git::Native: ssh hostkey for '$host' did NOT match the "
. "$host_seen known_hosts entr" . ( $host_seen == 1 ? 'y' : 'ies' )
. " for it â server offered a key type you have not cached, or the "
. "key changed. Run `ssh-keyscan $host >> ~/.ssh/known_hosts`, or set "
( run in 1.104 second using v1.01-cache-2.11-cpan-14f38c9f855 )