diff --git a/base/libgit2/callbacks.jl b/base/libgit2/callbacks.jl index c34029eea9c3b..2f7fcb4f59ae5 100644 --- a/base/libgit2/callbacks.jl +++ b/base/libgit2/callbacks.jl @@ -184,6 +184,18 @@ function authenticate_userpass(libgit2credptr::Ptr{Ptr{Void}}, p::CredentialPayl cred.pass = "" end + if p.use_git_helpers && (!revised || !isfilled(cred)) + git_cred = GitCredential(p.config, p.url) + + if isfilled(git_cred) + cred.user = Base.get(git_cred.username, "") + cred.pass = Base.get(git_cred.password, "") + revised = true + end + + p.use_git_helpers = false + end + if p.remaining_prompts > 0 && (!revised || !isfilled(cred)) url = git_url(scheme=p.scheme, host=p.host) username = isempty(cred.user) ? p.username : cred.user diff --git a/base/libgit2/gitcredential.jl b/base/libgit2/gitcredential.jl new file mode 100644 index 0000000000000..bde319981ccce --- /dev/null +++ b/base/libgit2/gitcredential.jl @@ -0,0 +1,289 @@ +""" + GitCredential + +Git credential information used in communication with git credential helpers. The field are +named using the [input/output key specification](https://git-scm.com/docs/git-credential#IOFMT). +""" +mutable struct GitCredential + protocol::Nullable{String} + host::Nullable{String} + path::Nullable{String} + username::Nullable{String} + password::Nullable{String} + + function GitCredential( + protocol::Nullable{<:AbstractString}, + host::Nullable{<:AbstractString}, + path::Nullable{<:AbstractString}, + username::Nullable{<:AbstractString}, + password::Nullable{<:AbstractString}) + c = new(protocol, host, path, username, password) + finalizer(c, securezero!) + return c + end +end + +function GitCredential( + protocol::Union{AbstractString,Void}=nothing, + host::Union{AbstractString,Void}=nothing, + path::Union{AbstractString,Void}=nothing, + username::Union{AbstractString,Void}=nothing, + password::Union{AbstractString,Void}=nothing) + GitCredential( + Nullable{String}(protocol), + Nullable{String}(host), + Nullable{String}(path), + Nullable{String}(username), + Nullable{String}(password)) +end + +function GitCredential(cfg::GitConfig, url::AbstractString) + fill!(cfg, parse(GitCredential, url)) +end + +function GitCredential(cred::UserPasswordCredentials, url::AbstractString) + git_cred = parse(GitCredential, url) + git_cred.username = Nullable{String}(cred.user) + git_cred.password = Nullable{String}(cred.pass) + return git_cred +end + +function securezero!(cred::GitCredential) + !isnull(cred.protocol) && securezero!(unsafe_get(cred.protocol)) + !isnull(cred.host) && securezero!(unsafe_get(cred.host)) + !isnull(cred.path) && securezero!(unsafe_get(cred.path)) + !isnull(cred.username) && securezero!(unsafe_get(cred.username)) + !isnull(cred.password) && securezero!(unsafe_get(cred.password)) + return cred +end + +function Base.:(==)(a::GitCredential, b::GitCredential) + isequal(a.protocol, b.protocol) && + isequal(a.host, b.host) && + isequal(a.path, b.path) && + isequal(a.username, b.username) && + isequal(a.password, b.password) +end + +""" + ismatch(url, git_cred) -> Bool + +Checks if the `git_cred` is valid for the given `url`. +""" +function ismatch(url::AbstractString, git_cred::GitCredential) + isempty(url) && return true + + m = match(URL_REGEX, url) + m === nothing && error("Unable to parse URL") + + # Note: missing URL groups match anything + (m[:scheme] === nothing ? true : isequal(Nullable(m[:scheme]), git_cred.protocol)) && + (m[:host] === nothing ? true : isequal(Nullable(m[:host]), git_cred.host)) && + (m[:path] === nothing ? true : isequal(Nullable(m[:path]), git_cred.path)) && + (m[:user] === nothing ? true : isequal(Nullable(m[:user]), git_cred.username)) +end + +function isfilled(cred::GitCredential) + !isnull(cred.username) && !isnull(cred.password) +end + +function Base.parse(::Type{GitCredential}, url::AbstractString) + m = match(URL_REGEX, url) + m === nothing && error("Unable to parse URL") + return GitCredential( + m[:scheme], + m[:host], + m[:path], + m[:user], + m[:password], + ) +end + +function Base.copy!(a::GitCredential, b::GitCredential) + # Note: deepcopy calls avoid issues with securezero! + a.protocol = deepcopy(b.protocol) + a.host = deepcopy(b.host) + a.path = deepcopy(b.path) + a.username = deepcopy(b.username) + a.password = deepcopy(b.password) + return a +end + +function Base.write(io::IO, cred::GitCredential) + !isnull(cred.protocol) && println(io, "protocol=", unsafe_get(cred.protocol)) + !isnull(cred.host) && println(io, "host=", unsafe_get(cred.host)) + !isnull(cred.path) && println(io, "path=", unsafe_get(cred.path)) + !isnull(cred.username) && println(io, "username=", unsafe_get(cred.username)) + !isnull(cred.password) && println(io, "password=", unsafe_get(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 == "url" + # Any components which are missing from the URL will be set to empty + # https://git-scm.com/docs/git-credential#git-credential-codeurlcode + copy!(cred, parse(GitCredential, value)) + else + setfield!(cred, Symbol(key), Nullable(String(value))) + end + end + + return cred +end + +function fill!(cfg::GitConfig, cred::GitCredential) + # When the username is missing default to using the username set in the configuration + if isnull(cred.username) + cred.username = default_username(cfg, cred) + end + + for helper in credential_helpers(cfg, cred) + fill!(helper, cred) + + # "Once Git has acquired both a username and a password, no more helpers will be + # tried." – https://git-scm.com/docs/gitcredentials#gitcredentials-helper + !isfilled(cred) && break + end + + return cred +end + +struct GitCredentialHelper + cmd::Cmd +end + +function Base.parse(::Type{GitCredentialHelper}, helper::AbstractString) + # The helper string can take on different behaviors depending on the value: + # - "Code after `!` evaluated in shell" – https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage + # - "If the helper name is not an absolute path, then the string `git credential-` is + # prepended." – https://git-scm.com/docs/gitcredentials#gitcredentials-helper + 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 Base.:(==)(a::GitCredentialHelper, b::GitCredentialHelper) + a.cmd == b.cmd +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 + write(input, cred) + write(input, "\n") + t = @async close(input) + + # Process the response from the helper + Base.read!(output, cred) + close(output) + wait(t) + + return cred +end + +function run(helper::GitCredentialHelper, operation::AbstractString, cred::GitCredential) + run!(helper, operation, deepcopy(cred)) +end + +# The available actions between using `git credential` and helpers are slightly different. +# We will directly interact with the helpers as that way we can request credential +# information without a prompt (helper `get` vs. git credential `fill`). +# https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage + +fill!(helper::GitCredentialHelper, cred::GitCredential) = run!(helper, "get", cred) +approve(helper::GitCredentialHelper, cred::GitCredential) = run(helper, "store", cred) +reject(helper::GitCredentialHelper, cred::GitCredential) = run(helper, "erase", cred) + +""" + credential_helpers(config, git_cred) -> Vector{GitCredentialHelper} + +Return all of the `GitCredentialHelper`s found within the provided `config` which are valid +for the specified `git_cred`. +""" +function credential_helpers(cfg::GitConfig, cred::GitCredential) + helpers = GitCredentialHelper[] + + # https://git-scm.com/docs/gitcredentials#gitcredentials-helper + for entry in GitConfigIter(cfg, r"credential.*\.helper") + section, url, name, value = split(entry) + @assert name == "helper" + + # Only use configuration settings where the URL applies to the git credential + ismatch(url, cred) || continue + + # An empty credential.helper resets the list to empty + isempty(value) && empty!(helpers) + + # Due to a bug in libgit2 iteration we may read credential helpers out of order. + # See: https://github.com/libgit2/libgit2/issues/4361 + # + # Typically the ordering doesn't matter but does in this particular case. Disabling + # credential helpers avoids potential issues with using the wrong credentials or + # writing credentials to the wrong helper. + if isempty(value) + Base.warn_once("Resetting the helper list is currently unsupported: " * + "ignoring all git credential helpers.") + return GitCredentialHelper[] + end + + push!(helpers, parse(GitCredentialHelper, value)) + end + + return helpers +end + +""" + default_username(config, git_cred) -> Nullable{String} + +Return the default username, if any, provided by the `config` which is valid for the +specified `git_cred`. +""" +function default_username(cfg::GitConfig, cred::GitCredential) + # https://git-scm.com/docs/gitcredentials#gitcredentials-username + for entry in GitConfigIter(cfg, r"credential.*\.username") + section, url, name, value = split(entry) + @assert name == "username" + + # Only use configuration settings where the URL applies to the git credential + ismatch(url, cred) || continue + return Nullable{String}(value) + end + + return Nullable{String}() +end + +approve(cfg::GitConfig, cred::AbstractCredentials, url::AbstractString) = nothing +reject(cfg::GitConfig, cred::AbstractCredentials, url::AbstractString) = nothing + +function approve(cfg::GitConfig, cred::UserPasswordCredentials, url::AbstractString) + git_cred = GitCredential(cred, url) + for helper in credential_helpers(cfg, git_cred) + approve(helper, git_cred) + end + + nothing +end + +function reject(cfg::GitConfig, cred::UserPasswordCredentials, url::AbstractString) + git_cred = GitCredential(cred, url) + for helper in credential_helpers(cfg, git_cred) + reject(helper, git_cred) + end + + securezero!(git_cred) + securezero!(cred) + + nothing +end diff --git a/base/libgit2/libgit2.jl b/base/libgit2/libgit2.jl index 0b8cef674f392..3230725142fd4 100644 --- a/base/libgit2/libgit2.jl +++ b/base/libgit2/libgit2.jl @@ -36,6 +36,7 @@ include("rebase.jl") include("blame.jl") include("status.jl") include("tree.jl") +include("gitcredential.jl") include("callbacks.jl") using .Error @@ -262,7 +263,7 @@ function fetch(repo::GitRepo; remote::AbstractString="origin", remoteurl::AbstractString="", refspecs::Vector{<:AbstractString}=AbstractString[], payload::Union{CredentialPayload,Nullable{<:AbstractCredentials}}=CredentialPayload()) - p = reset!(deprecate_nullable_creds(:fetch, "repo", payload)) + p = reset!(deprecate_nullable_creds(:fetch, "repo", payload), GitConfig(repo)) rmt = if isempty(remoteurl) get(GitRemote, repo, remote) else @@ -304,7 +305,7 @@ function push(repo::GitRepo; remote::AbstractString="origin", refspecs::Vector{<:AbstractString}=AbstractString[], force::Bool=false, payload::Union{CredentialPayload,Nullable{<:AbstractCredentials}}=CredentialPayload()) - p = reset!(deprecate_nullable_creds(:push, "repo", payload)) + p = reset!(deprecate_nullable_creds(:push, "repo", payload), GitConfig(repo)) rmt = if isempty(remoteurl) get(GitRemote, repo, remote) else diff --git a/base/libgit2/types.jl b/base/libgit2/types.jl index 97c4410b1faf0..20a2efc643239 100644 --- a/base/libgit2/types.jl +++ b/base/libgit2/types.jl @@ -864,6 +864,46 @@ function Base.show(io::IO, ce::ConfigEntry) print(io, "ConfigEntry(\"", unsafe_string(ce.name), "\", \"", unsafe_string(ce.value), "\")") end +""" + split(ce::LibGit2.ConfigEntry) -> Tuple{String,String,String,String} + +Break the `ConfigEntry` up to the following pieces: section, subsection, name, and value. + +# Examples +Given the git configuration file containing: +``` +[credential "https://example.com"] + username = me +``` + +The `ConfigEntry` would look like the following: + +```julia-repl +julia> entry +ConfigEntry("credential.https://example.com.username", "me") + +julia> split(entry) +("credential", "https://example.com", "username", "me") +``` + +Refer to the [git config syntax documenation](https://git-scm.com/docs/git-config#_syntax) +for more details. +""" +function Base.split(ce::ConfigEntry) + key = unsafe_string(ce.name) + + # Determine the positions of the delimiters + subsection_delim = search(key, '.') + name_delim = rsearch(key, '.') + + section = SubString(key, 1, subsection_delim - 1) + subsection = SubString(key, subsection_delim + 1, name_delim - 1) + name = SubString(key, name_delim + 1) + value = unsafe_string(ce.value) + + return (section, subsection, name, value) +end + # Abstract object types abstract type AbstractGitObject end Base.isempty(obj::AbstractGitObject) = (obj.ptr == C_NULL) @@ -1215,14 +1255,18 @@ different URL. mutable struct CredentialPayload <: Payload explicit::Nullable{AbstractCredentials} cache::Nullable{CachedCredentials} - allow_ssh_agent::Bool # Allow the use of the SSH agent to get credentials - allow_prompt::Bool # Allow prompting the user for credentials + allow_ssh_agent::Bool # Allow the use of the SSH agent to get credentials + allow_git_helpers::Bool # Allow the use of git credential helpers + allow_prompt::Bool # Allow prompting the user for credentials + + config::GitConfig # Ephemeral state fields credential::Nullable{AbstractCredentials} first_pass::Bool use_ssh_agent::Bool use_env::Bool + use_git_helpers::Bool remaining_prompts::Int url::String @@ -1232,10 +1276,13 @@ mutable struct CredentialPayload <: Payload function CredentialPayload( credential::Nullable{<:AbstractCredentials}=Nullable{AbstractCredentials}(), - cache::Nullable{CachedCredentials}=Nullable{CachedCredentials}(); + cache::Nullable{CachedCredentials}=Nullable{CachedCredentials}(), + config::GitConfig=GitConfig(); allow_ssh_agent::Bool=true, + allow_git_helpers::Bool=true, allow_prompt::Bool=true) - payload = new(credential, cache, allow_ssh_agent, allow_prompt) + + payload = new(credential, cache, allow_ssh_agent, allow_git_helpers, allow_prompt, config) return reset!(payload) end end @@ -1249,16 +1296,18 @@ function CredentialPayload(cache::CachedCredentials; kwargs...) end """ - reset!(payload) -> CredentialPayload + reset!(payload, [config]) -> CredentialPayload Reset the `payload` state back to the initial values so that it can be used again within -the credential callback. +the credential callback. If a `config` is provided the configuration will also be updated. """ -function reset!(p::CredentialPayload) +function reset!(p::CredentialPayload, config::GitConfig=p.config) + p.config = config p.credential = Nullable{AbstractCredentials}() p.first_pass = true p.use_ssh_agent = p.allow_ssh_agent p.use_env = true + p.use_git_helpers = p.allow_git_helpers p.remaining_prompts = p.allow_prompt ? 3 : 0 p.url = "" p.scheme = "" @@ -1281,6 +1330,9 @@ function approve(p::CredentialPayload) if !isnull(p.cache) approve(unsafe_get(p.cache), cred, p.url) end + if p.allow_git_helpers + approve(p.config, cred, p.url) + end end """ @@ -1296,4 +1348,7 @@ function reject(p::CredentialPayload) if !isnull(p.cache) reject(unsafe_get(p.cache), cred, p.url) end + if p.allow_git_helpers + reject(p.config, cred, p.url) + end end diff --git a/test/libgit2-helpers.jl b/test/libgit2-helpers.jl index 9e83b0e124301..ef94768936144 100644 --- a/test/libgit2-helpers.jl +++ b/test/libgit2-helpers.jl @@ -3,6 +3,8 @@ import Base.LibGit2: AbstractCredentials, UserPasswordCredentials, SSHCredentials, CachedCredentials, CredentialPayload, Payload +const DEFAULT_PAYLOAD = CredentialPayload(allow_ssh_agent=false, allow_git_helpers=false) + """ Emulates the LibGit2 credential loop to allows testing of the credential_callback function without having to authenticate against a real server. @@ -58,7 +60,7 @@ function credential_loop( valid_credential::UserPasswordCredentials, url::AbstractString, user::Nullable{<:AbstractString}=Nullable{String}(), - payload::CredentialPayload=CredentialPayload()) + payload::CredentialPayload=DEFAULT_PAYLOAD) credential_loop(valid_credential, url, user, 0x000001, payload) end @@ -66,7 +68,7 @@ function credential_loop( valid_credential::SSHCredentials, url::AbstractString, user::Nullable{<:AbstractString}=Nullable{String}(), - payload::CredentialPayload=CredentialPayload(allow_ssh_agent=false)) + payload::CredentialPayload=DEFAULT_PAYLOAD) credential_loop(valid_credential, url, user, 0x000046, payload) end @@ -74,6 +76,6 @@ function credential_loop( valid_credential::AbstractCredentials, url::AbstractString, user::AbstractString, - payload::CredentialPayload=CredentialPayload(allow_ssh_agent=false)) + payload::CredentialPayload=DEFAULT_PAYLOAD) credential_loop(valid_credential, url, Nullable(user), payload) end diff --git a/test/libgit2-online.jl b/test/libgit2-online.jl index 39c31a5b1946d..c1e00ce1b9bfa 100644 --- a/test/libgit2-online.jl +++ b/test/libgit2-online.jl @@ -9,7 +9,7 @@ mktempdir() do dir @testset "Cloning repository" begin @testset "with 'https' protocol" begin repo_path = joinpath(dir, "Example1") - payload = LibGit2.CredentialPayload(allow_prompt=false) + payload = LibGit2.CredentialPayload(allow_prompt=false, allow_git_helpers=false) repo = LibGit2.clone(repo_url, repo_path, payload=payload) try @test isdir(repo_path) @@ -24,7 +24,7 @@ mktempdir() do dir repo_path = joinpath(dir, "Example2") # credentials are required because github tries to authenticate on unknown repo cred = LibGit2.UserPasswordCredentials("JeffBezanson", "hunter2") # make sure Jeff is using a good password :) - payload = LibGit2.CredentialPayload(cred, allow_prompt=false) + payload = LibGit2.CredentialPayload(cred, allow_prompt=false, allow_git_helpers=false) LibGit2.clone(repo_url*randstring(10), repo_path, payload=payload) error("unexpected") catch ex @@ -38,7 +38,7 @@ mktempdir() do dir repo_path = joinpath(dir, "Example3") # credentials are required because github tries to authenticate on unknown repo cred = LibGit2.UserPasswordCredentials("","") # empty credentials cause authentication error - payload = LibGit2.CredentialPayload(cred, allow_prompt=false) + payload = LibGit2.CredentialPayload(cred, allow_prompt=false, allow_git_helpers=false) LibGit2.clone(repo_url*randstring(10), repo_path, payload=payload) error("unexpected") catch ex diff --git a/test/libgit2.jl b/test/libgit2.jl index 5d79976a54b2d..a431f937405cf 100644 --- a/test/libgit2.jl +++ b/test/libgit2.jl @@ -321,6 +321,83 @@ end end end +@testset "GitCredential" begin + @testset "missing" begin + str = "" + cred = read!(IOBuffer(str), LibGit2.GitCredential()) + @test cred == LibGit2.GitCredential() + @test sprint(write, cred) == str + end + + @testset "empty" begin + str = """ + protocol= + host= + path= + username= + password= + """ + cred = read!(IOBuffer(str), LibGit2.GitCredential()) + @test cred == LibGit2.GitCredential("", "", "", "", "") + @test sprint(write, cred) == str + end + + @testset "input/output" begin + str = """ + protocol=https + host=example.com + username=alice + password=***** + """ + cred = read!(IOBuffer(str), LibGit2.GitCredential()) + @test cred == LibGit2.GitCredential("https", "example.com", nothing, "alice", "*****") + @test sprint(write, cred) == str + end + + @testset "URL input/output" begin + str = """ + host=example.com + password=bar + url=https://a@b/c + username=foo + """ + expected = """ + protocol=https + host=b + path=c + username=foo + """ + cred = read!(IOBuffer(str), LibGit2.GitCredential()) + @test cred == LibGit2.GitCredential("https", "b", "c", "foo", nothing) + @test sprint(write, cred) == expected + end + + @testset "ismatch" begin + # Equal + cred = LibGit2.GitCredential("https", "github.com") + @test LibGit2.ismatch("https://github.com", cred) + + # Credential hostname is different + cred = LibGit2.GitCredential("https", "github.com") + @test !LibGit2.ismatch("https://myhost", cred) + + # Credential is less specific than URL + cred = LibGit2.GitCredential("https") + @test !LibGit2.ismatch("https://github.com", cred) + + # Credential is more specific than URL + cred = LibGit2.GitCredential("https", "github.com", "path", "user", "pass") + @test LibGit2.ismatch("https://github.com", cred) + + # Credential needs to have an "" username to match + cred = LibGit2.GitCredential("https", "github.com", nothing, "") + @test LibGit2.ismatch("https://@github.com", cred) + + cred = LibGit2.GitCredential("https", "github.com", nothing, nothing) + @test !LibGit2.ismatch("https://@github.com", cred) + end +end + mktempdir() do dir # test parameters repo_url = "https://github.com/JuliaLang/Example.jl" @@ -384,11 +461,12 @@ mktempdir() do dir # Write config entries with duplicate names open(config_path, "a") do fp write(fp, """ - [credential] - \thelper = store - [credential] - \thelper = cache - """) + [credential] + helper = store + username = julia + [credential] + helper = cache + """) end LibGit2.with(LibGit2.GitConfig(config_path, LibGit2.Consts.CONFIG_LEVEL_APP)) do cfg @@ -1458,6 +1536,151 @@ mktempdir() do dir @test cred.pass != "password" end + @testset "Git Credential Username" begin + @testset "fill username" begin + config_path = joinpath(dir, config_file) + isfile(config_path) && rm(config_path) + + LibGit2.with(LibGit2.GitConfig(config_path, LibGit2.Consts.CONFIG_LEVEL_APP)) do cfg + # No credential settings should be set for these tests + @test isempty(collect(LibGit2.GitConfigIter(cfg, r"credential.*"))) + + # No credential settings in configuration. + cred = LibGit2.GitCredential("https", "github.com") + username = LibGit2.default_username(cfg, cred) + @test isnull(username) + + # Add a credential setting for a specific for a URL + LibGit2.set!(cfg, "credential.https://github.com.username", "foo") + + cred = LibGit2.GitCredential("https", "github.com") + username = LibGit2.default_username(cfg, cred) + @test !isnull(username) + @test get(username) == "foo" + + cred = LibGit2.GitCredential("https", "mygithost") + username = LibGit2.default_username(cfg, cred) + @test isnull(username) + + # Add a global credential setting after the URL specific setting. The first + # setting to match will be the one that is used. + LibGit2.set!(cfg, "credential.username", "bar") + + cred = LibGit2.GitCredential("https", "github.com") + username = LibGit2.default_username(cfg, cred) + @test !isnull(username) + @test get(username) == "foo" + + cred = LibGit2.GitCredential("https", "mygithost") + username = LibGit2.default_username(cfg, cred) + @test !isnull(username) + @test get(username) == "bar" + end + end + + @testset "empty username" begin + config_path = joinpath(dir, config_file) + isfile(config_path) && rm(config_path) + + LibGit2.with(LibGit2.GitConfig(config_path, LibGit2.Consts.CONFIG_LEVEL_APP)) do cfg + # No credential settings should be set for these tests + @test isempty(collect(LibGit2.GitConfigIter(cfg, r"credential.*"))) + + # An empty username should count as being set + LibGit2.set!(cfg, "credential.https://github.com.username", "") + LibGit2.set!(cfg, "credential.username", "name") + + cred = LibGit2.GitCredential("https", "github.com") + username = LibGit2.default_username(cfg, cred) + @test !isnull(username) + @test get(username) == "" + + cred = LibGit2.GitCredential("https", "mygithost", "path") + username = LibGit2.default_username(cfg, cred) + @test !isnull(username) + @test get(username) == "name" + end + end + end + + @testset "GitCredentialHelper" begin + GitCredentialHelper = LibGit2.GitCredentialHelper + GitCredential = LibGit2.GitCredential + + @testset "parse" begin + @test parse(GitCredentialHelper, "!echo hello") == GitCredentialHelper(`echo hello`) + @test parse(GitCredentialHelper, "/bin/bash") == GitCredentialHelper(`/bin/bash`) + @test parse(GitCredentialHelper, "store") == GitCredentialHelper(`git credential-store`) + end + + @testset "empty helper" begin + config_path = joinpath(dir, config_file) + + # Note: LibGit2.set! doesn't allow us to set duplicates or ordering + open(config_path, "w+") do fp + write(fp, """ + [credential] + helper = !echo first + [credential "https://mygithost"] + helper = "" + [credential] + helper = !echo second + """) + end + + LibGit2.with(LibGit2.GitConfig(config_path, LibGit2.Consts.CONFIG_LEVEL_APP)) do cfg + @test length(collect(LibGit2.GitConfigIter(cfg, r"credential.*"))) == 3 + + expected = [ + GitCredentialHelper(`echo first`), + GitCredentialHelper(`echo second`), + ] + + @test LibGit2.credential_helpers(cfg, GitCredential("https", "github.com")) == expected + @test_broken LibGit2.credential_helpers(cfg, GitCredential("https", "mygithost")) == expected[2] + end + end + + @testset "approve/reject" begin + # In order to use the "store" credential helper `git` needs to be installed and + # on the path. + git_installed = try + success(`git --version`) + catch + false + end + + if git_installed + config_path = joinpath(dir, config_file) + credential_file = joinpath(dir, ".git-credentials") + + isfile(config_path) && rm(config_path) + + LibGit2.with(LibGit2.GitConfig(config_path, LibGit2.Consts.CONFIG_LEVEL_APP)) do cfg + @test !isfile(credential_file) + @test isempty(LibGit2.get(cfg, "credential.helper", "")) + + helper = parse(LibGit2.GitCredentialHelper, "store") # Requires `git` + LibGit2.set!(cfg, "credential.helper", "store") + + # Set HOME to control where .git-credentials file is written. + withenv(HOME => dir) do + query = LibGit2.GitCredential("https", "mygithost") + filled = LibGit2.GitCredential("https", "mygithost", nothing, "bob", "s3cre7") + + @test LibGit2.fill!(helper, deepcopy(query)) == query + + LibGit2.approve(helper, filled) + @test LibGit2.fill!(helper, deepcopy(query)) == filled + + LibGit2.reject(helper, filled) + @test LibGit2.fill!(helper, deepcopy(query)) == query + end + end + end + end + end + # The following tests require that we can fake a TTY so that we can provide passwords # which use the `getpass` function. At the moment we can only fake this on UNIX based # systems. @@ -1797,7 +2020,8 @@ mktempdir() do dir function gen_ex(; username="git") quote include($LIBGIT2_HELPER_PATH) - payload = CredentialPayload(allow_ssh_agent=true, allow_prompt=false) + payload = CredentialPayload(allow_prompt=false, allow_ssh_agent=true, + allow_git_helpers=false) credential_loop($valid_cred, $url, Nullable{String}($username), payload) end end @@ -1885,7 +2109,8 @@ mktempdir() do dir ssh_ex = quote include($LIBGIT2_HELPER_PATH) - payload = CredentialPayload(allow_ssh_agent=false, allow_prompt=true) + payload = CredentialPayload(allow_prompt=true, allow_ssh_agent=false, + allow_git_helpers=false) err, auth_attempts = credential_loop($valid_cred, $url, "git", payload) (err, auth_attempts, payload.credential) end @@ -1936,8 +2161,9 @@ mktempdir() do dir function gen_ex(cred; allow_prompt=true, allow_ssh_agent=false) quote include($LIBGIT2_HELPER_PATH) - payload = CredentialPayload($cred, allow_ssh_agent=$allow_ssh_agent, - allow_prompt=$allow_prompt) + payload = CredentialPayload($cred, allow_prompt=$allow_prompt, + allow_ssh_agent=$allow_ssh_agent, + allow_git_helpers=false) credential_loop($valid_cred, $url, $username, payload) end end @@ -1965,7 +2191,8 @@ mktempdir() do dir function gen_ex(cred; allow_prompt=true) quote include($LIBGIT2_HELPER_PATH) - payload = CredentialPayload($cred, allow_prompt=$allow_prompt) + payload = CredentialPayload($cred, allow_prompt=$allow_prompt, + allow_git_helpers=false) credential_loop($valid_cred, $url, "", payload) end end @@ -2000,7 +2227,8 @@ mktempdir() do dir include($LIBGIT2_HELPER_PATH) cache = CachedCredentials() $(cached_cred !== nothing && :(LibGit2.approve(cache, $cached_cred, $url))) - payload = CredentialPayload(cache, allow_prompt=$allow_prompt) + payload = CredentialPayload(cache, allow_prompt=$allow_prompt, + allow_git_helpers=false) err, auth_attempts = credential_loop($valid_cred, $url, "", payload) (err, auth_attempts, cache) end @@ -2062,7 +2290,8 @@ mktempdir() do dir expect_ssh_ex = quote include($LIBGIT2_HELPER_PATH) valid_cred = LibGit2.UserPasswordCredentials("foo", "bar") - payload = CredentialPayload(valid_cred, allow_ssh_agent=false) + payload = CredentialPayload(valid_cred, allow_ssh_agent=false, + allow_git_helpers=false) credential_loop(valid_cred, "ssh://github.com/repo", Nullable(""), Cuint(LibGit2.Consts.CREDTYPE_SSH_KEY), payload) end @@ -2075,7 +2304,8 @@ mktempdir() do dir expect_https_ex = quote include($LIBGIT2_HELPER_PATH) valid_cred = LibGit2.SSHCredentials("foo", "", "", "") - payload = CredentialPayload(valid_cred, allow_ssh_agent=false) + payload = CredentialPayload(valid_cred, allow_ssh_agent=false, + allow_git_helpers=false) credential_loop(valid_cred, "https://github.com/repo", Nullable(""), Cuint(LibGit2.Consts.CREDTYPE_USERPASS_PLAINTEXT), payload) end @@ -2095,7 +2325,8 @@ mktempdir() do dir ex = quote include($LIBGIT2_HELPER_PATH) valid_cred = LibGit2.UserPasswordCredentials("foo", "bar") - payload = CredentialPayload(valid_cred, allow_ssh_agent=false) + payload = CredentialPayload(valid_cred, allow_ssh_agent=false, + allow_git_helpers=false) credential_loop(valid_cred, "foo://github.com/repo", Nullable(""), $allowed_types, payload) end @@ -2119,7 +2350,7 @@ mktempdir() do dir include($LIBGIT2_HELPER_PATH) valid_cred = LibGit2.UserPasswordCredentials($valid_username, $valid_password) user = Nullable{String}() - payload = CredentialPayload() + payload = CredentialPayload(allow_git_helpers=false) first_result = credential_loop(valid_cred, $(urls[1]), user, payload) LibGit2.reset!(payload) second_result = credential_loop(valid_cred, $(urls[2]), user, payload)