Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: LibGit2 support for Git credential helpers #20725

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 319 additions & 157 deletions base/libgit2/callbacks.jl

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions base/libgit2/config.jl
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,61 @@ function set!(c::GitConfig, name::AbstractString, value::Int64)
@check ccall((:git_config_set_int64, :libgit2), Cint,
(Ptr{Void}, Cstring, Cintmax_t), c.ptr, name, value)
end

function GitConfigIter(cfg::GitConfig)
ci_ptr = Ref{Ptr{Void}}(C_NULL)
@check ccall((:git_config_iterator_new, :libgit2), Cint,
(Ptr{Ptr{Void}}, Ptr{Void}), ci_ptr, cfg.ptr)
return GitConfigIter(ci_ptr[])
end

function GitConfigIter(cfg::GitConfig, name::AbstractString)
ci_ptr = Ref{Ptr{Void}}(C_NULL)
@check ccall((:git_config_multivar_iterator_new, :libgit2), Cint,
(Ptr{Ptr{Void}}, Ptr{Void}, Cstring, Cstring),
ci_ptr, cfg.ptr, name, C_NULL)
return GitConfigIter(ci_ptr[])
end

function GitConfigIter(cfg::GitConfig, name::AbstractString, value::Regex)
ci_ptr = Ref{Ptr{Void}}(C_NULL)
@check ccall((:git_config_multivar_iterator_new, :libgit2), Cint,
(Ptr{Ptr{Void}}, Ptr{Void}, Cstring, Cstring),
ci_ptr, cfg.ptr, name, value.pattern)
return GitConfigIter(ci_ptr[])
end

function GitConfigIter(cfg::GitConfig, name::Regex)
ci_ptr = Ref{Ptr{Void}}(C_NULL)
@check ccall((:git_config_iterator_glob_new, :libgit2), Cint,
(Ptr{Ptr{Void}}, Ptr{Void}, Cstring),
ci_ptr, cfg.ptr, name.pattern)
return GitConfigIter(ci_ptr[])
end

function Base.start(ci::GitConfigIter)
entry_ptr_ptr = Ref{Ptr{ConfigEntry}}(C_NULL)
err = ccall((:git_config_next, :libgit2), Cint,
(Ptr{Ptr{ConfigEntry}}, Ptr{Void}), entry_ptr_ptr, ci.ptr)
if err == Int(Error.GIT_OK)
unsafe_load(entry_ptr_ptr[])
else
nothing
end
end

Base.done(ci::GitConfigIter, state) = state === nothing

function Base.next(ci::GitConfigIter, state)
entry = state
entry_ptr_ptr = Ref{Ptr{ConfigEntry}}(C_NULL)
err = ccall((:git_config_next, :libgit2), Cint,
(Ptr{Ptr{ConfigEntry}}, Ptr{Void}), entry_ptr_ptr, ci.ptr)
if err == Int(Error.GIT_OK)
(entry, unsafe_load(entry_ptr_ptr[]))
else
(entry, nothing)
end
end

Base.iteratorsize(::Type{GitConfigIter}) = Base.SizeUnknown()
4 changes: 4 additions & 0 deletions base/libgit2/error.jl
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ function GitError(code::Integer)
return GitError(err_class, err_code, err_msg)
end

function Base.:(==)(a::GitError, b::GitError)
a.class == b.class && a.code == b.code && a.msg == b.msg
end

end # Error module

macro check(git_func)
Expand Down
188 changes: 188 additions & 0 deletions base/libgit2/gitcredential.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
function Base.parse(::Type{GitCredentialHelper}, helper::AbstractString)
if startswith(helper, "!")
cmd_str = helper[2:end]
elseif isabspath(first(Base.shell_split(helper)))
cmd_str = helper
else
cmd_str = "git credential-$helper"
end

GitCredentialHelper(`$(Base.shell_split(cmd_str)...)`)
end

function run!(helper::GitCredentialHelper, operation::AbstractString, cred::GitCredential)
cmd = `$(helper.cmd) $operation`
output, input, p = readandwrite(cmd)

# Provide the helper with the credential information we know
Base.write(input, cred)
Base.write(input, "\n")
close(input)

# Process the response from the helper
Base.read!(output, cred)
close(output)

return cred
end

function run(helper::GitCredentialHelper, operation::AbstractString, cred::GitCredential)
updated_cred = deepcopy(cred)
run!(helper, operation, updated_cred)
end

function Base.parse(::Type{GitCredential}, url::AbstractString)
# TODO: It appears that the Git internals expect the contents to be URL encoded:
# https://github.com/git/git/blob/24321375cda79f141be72d1a842e930df6f41725/credential.c#L324
#
# Match one of:
# (1) proto://<host>/...
# (2) proto://<user>@<host>/...
# (3) proto://<user>:<pass>@<host>/...
m = match(URL_REGEX, url)
m === nothing && error("Unable to parse URL")
host = m[:host] * (m[:port] != nothing ? ":$(m[:port])" : "")
return GitCredential(
m[:protocol],
host,
m[:path] === nothing ? "" : m[:path],
m[:user] === nothing ? "" : m[:user],
m[:password] === nothing ? "" : m[:password],
)
end

function merge!(a::GitCredential, b::GitCredential)
!isempty(b.protocol) && (a.protocol = b.protocol)
!isempty(b.host) && (a.host = b.host)
!isempty(b.path) && (a.path = b.path)
!isempty(b.username) && (a.username = b.username)
!isempty(b.password) && (a.password = b.password)
return a
end

function Base.:(==)(a::GitCredential, b::GitCredential)
return (
a.protocol == b.protocol &&
a.host == b.host &&
a.path == b.path &&
a.username == b.username &&
a.password == b.password
)
end

function Base.contains(haystack::GitCredential, needle::GitCredential)
field_contains(h, n) = isempty(n) || (!isempty(h) && h == n)
return (
field_contains(haystack.protocol, needle.protocol) &&
field_contains(haystack.host, needle.host) &&
field_contains(haystack.path, needle.path) &&
field_contains(haystack.username, needle.username)
)
end

function Base.write(io::IO, cred::GitCredential)
!isempty(cred.protocol) && println(io, "protocol=", cred.protocol)
!isempty(cred.host) && println(io, "host=", cred.host)
!isempty(cred.path) && println(io, "path=", cred.path)
!isempty(cred.username) && println(io, "username=", cred.username)
!isempty(cred.password) && println(io, "password=", cred.password)
nothing
end

function Base.read!(io::IO, cred::GitCredential)
# https://git-scm.com/docs/git-credential#IOFMT
while !eof(io)
key, value = split(readline(io), '=')

if key == "protocol"
cred.protocol = value
elseif key == "host"
cred.host = value
elseif key == "path"
cred.path = value
elseif key == "username"
cred.username = value
elseif key == "password"
cred.password = value
elseif key == "url"
merge!(cred, parse(GitCredential, value))
end
end

return cred
end

Base.read(io::IO, ::Type{GitCredential}) = Base.read!(io, GitCredential())

"""
Git credential helpers that are relevant to the given credentials. If the credentials do not
contain a username one may be added at this step by the configuration.
"""
function helpers!(cfg::GitConfig, cred::GitCredential)
# Note: Should be quoting user input but `\Q` and `\E` isn't supported by libgit2
# ci = LibGit2.GitConfigIter(cfg, Regex("credential(\\.$protocol://$host)?\\.helper"))

# Note: We will emulate the way Git reads the the configuration file which is from
# top to bottom with no precedence on specificity.
helpers = GitCredentialHelper[]
for entry in GitConfigIter(cfg, r"credential.*")
name, value = unsafe_string(entry.name), unsafe_string(entry.value)

a, b = search(name, '.'), rsearch(name, '.')
url = SubString(name, a + 1, b - 1)
token = SubString(name, b + 1)

if !isempty(url)
!contains(cred, parse(GitCredential, url)) && continue
end

if token == "helper"
push!(helpers, parse(GitCredentialHelper, value))
elseif token == "username"
if isempty(cred.username)
cred.username = value
end
end
end

return helpers
end

function filled(cred::GitCredential)
!isempty(cred.username) && !isempty(cred.password)
end

function fill!(helpers::AbstractArray{GitCredentialHelper}, cred::GitCredential)
filled(cred) && return cred
for helper in helpers
run!(helper, "get", cred)
filled(cred) && break
end
return cred
end

function fill(helpers::AbstractArray{GitCredentialHelper}, cred::GitCredential)
new_cred = deepcopy(cred)
fill!(helpers, new_cred)
end

function approve(helpers::AbstractArray{GitCredentialHelper}, cred::GitCredential)
!filled(cred) && error("Credentials are not filled")
for helper in helpers
run(helper, "store", cred)
end
end

function reject(helpers::AbstractArray{GitCredentialHelper}, cred::GitCredential)
!filled(cred) && return

for helper in helpers
run(helper, "erase", cred)
end

Base.securezero!(cred.username)
Base.securezero!(cred.password)
cred.username = ""
cred.password = ""
nothing
end
54 changes: 36 additions & 18 deletions base/libgit2/libgit2.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ include("diff.jl")
include("rebase.jl")
include("status.jl")
include("tree.jl")
include("gitcredential.jl")
include("callbacks.jl")

using .Error
Expand Down Expand Up @@ -202,10 +203,6 @@ function set_remote_url(path::AbstractString, url::AbstractString; remote::Abstr
end
end

function make_payload(payload::Nullable{<:AbstractCredentials})
Ref{Nullable{AbstractCredentials}}(payload)
end

"""
fetch(repo::GitRepo; kwargs...)

Expand All @@ -218,24 +215,31 @@ The keyword arguments are:
* `remoteurl::AbstractString=""`: the URL of `remote`. If not specified,
will be assumed based on the given name of `remote`.
* `refspecs=AbstractString[]`: determines properties of the fetch.
* `payload=Nullable{AbstractCredentials}()`: provides credentials, if necessary,
* `credentials=Nullable{CachedCredentials}()`: provides credentials, if necessary,
for instance if `remote` is a private repository.

Equivalent to `git fetch [<remoteurl>|<repo>] [<refspecs>]`.
"""
function fetch(repo::GitRepo; remote::AbstractString="origin",
function fetch(repo::GitRepo;
remote::AbstractString="origin",
remoteurl::AbstractString="",
refspecs::Vector{<:AbstractString}=AbstractString[],
payload::Nullable{<:AbstractCredentials}=Nullable{AbstractCredentials}())
credentials::Nullable{CachedCredentials}=Nullable{CachedCredentials}())
rmt = if isempty(remoteurl)
get(GitRemote, repo, remote)
else
GitRemoteAnon(repo, remoteurl)
end

payload = RemotePayload(credentials, GitConfig(repo))
try
payload = make_payload(payload)
fo = FetchOptions(callbacks=RemoteCallbacks(credentials_cb(), payload))
fetch(rmt, refspecs, msg="from $(url(rmt))", options = fo)
result = fetch(rmt, refspecs, msg="from $(url(rmt))", options = fo)
credentials_approve(payload)
return result
catch err
isa(err, GitError) && err.code == Error.EAUTH && credentials_reject(payload)
rethrow()
finally
close(rmt)
end
Expand All @@ -252,25 +256,32 @@ The keyword arguments are:
* `refspecs=AbstractString[]`: determines properties of the push.
* `force::Bool=false`: determines if the push will be a force push,
overwriting the remote branch.
* `payload=Nullable{AbstractCredentials}()`: provides credentials, if necessary,
* `credentials=Nullable{CachedCredentials}()`: provides credentials, if necessary,
for instance if `remote` is a private repository.

Equivalent to `git push [<remoteurl>|<repo>] [<refspecs>]`.
"""
function push(repo::GitRepo; remote::AbstractString="origin",
function push(repo::GitRepo;
remote::AbstractString="origin",
remoteurl::AbstractString="",
refspecs::Vector{<:AbstractString}=AbstractString[],
force::Bool=false,
payload::Nullable{<:AbstractCredentials}=Nullable{AbstractCredentials}())
credentials::Nullable{CachedCredentials}=Nullable{CachedCredentials}())
rmt = if isempty(remoteurl)
get(GitRemote, repo, remote)
else
GitRemoteAnon(repo, remoteurl)
end

payload = RemotePayload(credentials, GitConfig(repo))
try
payload = make_payload(payload)
push_opts=PushOptions(callbacks=RemoteCallbacks(credentials_cb(), payload))
push(rmt, refspecs, force=force, options=push_opts)
result = push(rmt, refspecs, force=force, options=push_opts)
credentials_approve(payload)
return result
catch err
isa(err, GitError) && err.code == Error.EAUTH && credentials_reject(payload)
rethrow()
finally
close(rmt)
end
Expand Down Expand Up @@ -429,7 +440,7 @@ The keyword arguments are:
* `remote_cb::Ptr{Void}=C_NULL`: a callback which will be used to create the remote
before it is cloned. If `C_NULL` (the default), no attempt will be made to create
the remote - it will be assumed to already exist.
* `payload::Nullable{P<:AbstractCredentials}=Nullable{AbstractCredentials}()`:
* `credentials::Nullable{CachedCredentials}=Nullable{CachedCredentials}()`:
provides credentials if necessary, for instance if the remote is a private
repository.

Expand All @@ -439,18 +450,25 @@ function clone(repo_url::AbstractString, repo_path::AbstractString;
branch::AbstractString="",
isbare::Bool = false,
remote_cb::Ptr{Void} = C_NULL,
payload::Nullable{<:AbstractCredentials}=Nullable{AbstractCredentials}())
credentials::Nullable{CachedCredentials}=Nullable{CachedCredentials}())
# setup clone options
lbranch = Base.cconvert(Cstring, branch)
payload = make_payload(payload)
payload = RemotePayload(credentials)
fetch_opts=FetchOptions(callbacks = RemoteCallbacks(credentials_cb(), payload))
clone_opts = CloneOptions(
bare = Cint(isbare),
checkout_branch = isempty(lbranch) ? Cstring(C_NULL) : Base.unsafe_convert(Cstring, lbranch),
fetch_opts=fetch_opts,
remote_cb = remote_cb
)
return clone(repo_url, repo_path, clone_opts)
try
repo = clone(repo_url, repo_path, clone_opts)
credentials_approve(payload)
return repo
catch err
isa(err, GitError) && err.code == Error.EAUTH && credentials_reject(payload)
rethrow()
end
end

""" git reset [<committish>] [--] <pathspecs>... """
Expand Down
Loading