Skip to content

Commit

Permalink
Allowing disabling the default prompt suffix in Base.getpass() (Jul…
Browse files Browse the repository at this point in the history
…iaLang#53614)

By default `Base.getpass()` will append `: ` to the prompt, but
sometimes that's undesirable, so now there's a `with_suffix` keyword
argument to disable it.

For context, my use-case is showing SSH prompts received from a server
verbatim to the user. I did attempt to write a test for this but it
would've required a lot of refactoring since `getpass()` is pretty
hardcoded to expect input from `stdin` :\ An alternative design would be
to have a `suffix=": "` argument instead, but I couldn't think of a good
usecase for a user putting the prompt suffix in a separate argument
instead of `message` itself.

---------

Co-authored-by: Matt Bauman <[email protected]>
  • Loading branch information
JamesWrigley and mbauman authored Jul 22, 2024
1 parent 5fae0ff commit b79856e
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 114 deletions.
26 changes: 21 additions & 5 deletions base/util.jl
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,29 @@ function securezero! end
unsafe_securezero!(p::Ptr{Cvoid}, len::Integer=1) = Ptr{Cvoid}(unsafe_securezero!(Ptr{UInt8}(p), len))

"""
Base.getpass(message::AbstractString) -> Base.SecretBuffer
Base.getpass(message::AbstractString; with_suffix::Bool=true) -> Base.SecretBuffer
Display a message and wait for the user to input a secret, returning an `IO`
object containing the secret.
object containing the secret. If `with_suffix` is `true` (the default), the
suffix `": "` will be appended to `message`.
!!! info "Windows"
Note that on Windows, the secret might be displayed as it is typed; see
`Base.winprompt` for securely retrieving username/password pairs from a
graphical interface.
!!! compat "Julia 1.12"
The `with_suffix` keyword argument requires at least Julia 1.12.
# Examples
```julia-repl
julia> Base.getpass("Secret")
Secret: SecretBuffer("*******")
julia> Base.getpass("Secret> "; with_suffix=false)
Secret> SecretBuffer("*******")
```
"""
function getpass end

Expand Down Expand Up @@ -339,11 +353,13 @@ function with_raw_tty(f::Function, input::TTY)
end
end

function getpass(input::TTY, output::IO, prompt::AbstractString)
function getpass(input::TTY, output::IO, prompt::AbstractString; with_suffix::Bool=true)
input === stdin || throw(ArgumentError("getpass only works for stdin"))
with_raw_tty(stdin) do
print(output, prompt, ": ")
print(output, prompt)
with_suffix && print(output, ": ")
flush(output)

s = SecretBuffer()
plen = 0
while true
Expand All @@ -364,7 +380,7 @@ end

# allow new getpass methods to be defined if stdin has been
# redirected to some custom stream, e.g. in IJulia.
getpass(prompt::AbstractString) = getpass(stdin, stdout, prompt)
getpass(prompt::AbstractString; with_suffix::Bool=true) = getpass(stdin, stdout, prompt; with_suffix)

"""
prompt(message; default="") -> Union{String, Nothing}
Expand Down
2 changes: 0 additions & 2 deletions contrib/generate_precompile.jl
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ Sys.__init_build()
if !isdefined(Base, :uv_eventloop)
Base.reinit_stdio()
end
Base.include(@__MODULE__, joinpath(Sys.BINDIR, "..", "share", "julia", "test", "testhelpers", "FakePTYs.jl"))
import .FakePTYs: open_fake_pty
using Base.Meta

## Debugging options
Expand Down
110 changes: 4 additions & 106 deletions stdlib/LibGit2/test/libgit2-tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -8,113 +8,11 @@ using Test
using Random, Serialization, Sockets

const BASE_TEST_PATH = joinpath(Sys.BINDIR, "..", "share", "julia", "test")
isdefined(Main, :FakePTYs) || @eval Main include(joinpath($(BASE_TEST_PATH), "testhelpers", "FakePTYs.jl"))
import .Main.FakePTYs: with_fake_pty

const timeout = 60

function challenge_prompt(code::Expr, challenges)
input_code = tempname()
open(input_code, "w") do fp
serialize(fp, code)
end
output_file = tempname()
torun = """
import LibGit2
using Serialization
result = open($(repr(input_code))) do fp
eval(deserialize(fp))
end
open($(repr(output_file)), "w") do fp
serialize(fp, result)
end"""
cmd = `$(Base.julia_cmd()) --startup-file=no -e $torun`
try
challenge_prompt(cmd, challenges)
return open(output_file, "r") do fp
deserialize(fp)
end
finally
isfile(output_file) && rm(output_file)
isfile(input_code) && rm(input_code)
end
return nothing
end
isdefined(Main, :ChallengePrompts) || @eval Main include(joinpath($(BASE_TEST_PATH), "testhelpers", "ChallengePrompts.jl"))
using .Main.ChallengePrompts: challenge_prompt as basic_challenge_prompt

function challenge_prompt(cmd::Cmd, challenges)
function format_output(output)
str = read(seekstart(output), String)
isempty(str) && return ""
return "Process output found:\n\"\"\"\n$str\n\"\"\""
end
out = IOBuffer()
with_fake_pty() do pts, ptm
p = run(detach(cmd), pts, pts, pts, wait=false) # getpass uses stderr by default
Base.close_stdio(pts)

# Kill the process if it takes too long. Typically occurs when process is waiting
# for input.
timer = Channel{Symbol}(1)
watcher = @async begin
waited = 0
while waited < timeout && process_running(p)
sleep(1)
waited += 1
end

if process_running(p)
kill(p)
put!(timer, :timeout)
elseif success(p)
put!(timer, :success)
else
put!(timer, :failure)
end

# SIGKILL stubborn processes
if process_running(p)
sleep(3)
process_running(p) && kill(p, Base.SIGKILL)
end
wait(p)
end

wroteall = false
try
for (challenge, response) in challenges
write(out, readuntil(ptm, challenge, keep=true))
if !isopen(ptm)
error("Could not locate challenge: \"$challenge\". ",
format_output(out))
end
write(ptm, response)
end
wroteall = true

# Capture output from process until `pts` is closed
write(out, ptm)
catch ex
if !(wroteall && ex isa Base.IOError && ex.code == Base.UV_EIO)
# ignore EIO from `ptm` after `pts` dies
error("Process failed possibly waiting for a response. ",
format_output(out))
end
end

status = fetch(timer)
close(ptm)
if status !== :success
if status === :timeout
error("Process timed out possibly waiting for a response. ",
format_output(out))
else
error("Failed process. ", format_output(out), "\n", p)
end
end
wait(watcher)
end
nothing
end
challenge_prompt(code::Expr, challenges) = basic_challenge_prompt(code, challenges; pkgs=["LibGit2"])
challenge_prompt(cmd::Cmd, challenges) = basic_challenge_prompt(cmd, challenges)

const LIBGIT2_MIN_VER = v"1.0.0"
const LIBGIT2_HELPER_PATH = joinpath(@__DIR__, "libgit2-helpers.jl")
Expand Down
29 changes: 29 additions & 0 deletions test/secretbuffer.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# This file is a part of Julia. License is MIT: https://julialang.org/license

const BASE_TEST_PATH = joinpath(Sys.BINDIR, "..", "share", "julia", "test")
isdefined(Main, :ChallengePrompts) || @eval Main include(joinpath($(BASE_TEST_PATH), "testhelpers", "ChallengePrompts.jl"))
using .Main.ChallengePrompts: challenge_prompt

using Base: SecretBuffer, SecretBuffer!, shred!, isshredded
using Test, Random

Expand Down Expand Up @@ -170,4 +174,29 @@ using Test, Random
@test read(s5) == read(s6) == codeunits(str)
shred!(s5); shred!(s6)
end

if !Sys.iswindows()
@testset "getpass" begin
v1, s1 = challenge_prompt(:(s=Base.getpass("LPAwVZM8D4I"); (read(s), Base.shred!(s))), ["LPAwVZM8D4I: " => "too many secrets\n"])
s2 = SecretBuffer("too many secrets")
@test s1 isa SecretBuffer
@test isshredded(s1)
@test v1 == read(s2) == codeunits("too many secrets")
shred!(s1); shred!(s2)

v3, s3 = challenge_prompt(:(s=Base.getpass("LPAwVZM8D4I> ", with_suffix=false); (read(s), Base.shred!(s))), ["LPAwVZM8D4I> " => "frperg\n"])
s4 = SecretBuffer("frperg")
@test s3 isa SecretBuffer
@test isshredded(s3)
@test v3 == read(s4) == codeunits("frperg")
shred!(s3); shred!(s4)

v5, s5 = challenge_prompt(:(s=Base.getpass("LPAwVZM8D4I> ", with_suffix=true); (read(s), Base.shred!(s))), ["LPAwVZM8D4I> : " => "frperg\n"])
s6 = SecretBuffer("frperg")
@test s5 isa SecretBuffer
@test isshredded(s5)
@test v5 == read(s6) == codeunits("frperg")
shred!(s5); shred!(s6)
end
end
end
123 changes: 123 additions & 0 deletions test/testhelpers/ChallengePrompts.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
module ChallengePrompts

include("FakePTYs.jl")
using .FakePTYs: with_fake_pty
using Serialization: serialize, deserialize

const timeout = 60

"""
challenge_prompt(code::Expr, challenges; pkgs=[])
Execute the passed code in a separate process, looking for
the passed prompts and responding as defined in the pairs of
(prompt, response) in the collection of challenges.
Optionally `import` the given `pkgs`.
Returns the value of the last expression.
"""
function challenge_prompt(code::Expr, challenges; pkgs=[])
input_code = tempname()
open(input_code, "w") do fp
serialize(fp, code)
end
output_file = tempname()
torun = """
$(isempty(pkgs) ? "" : string("import ", join(pkgs, ", ")))
using Serialization
result = open($(repr(input_code))) do fp
eval(deserialize(fp))
end
open($(repr(output_file)), "w") do fp
serialize(fp, result)
end"""
cmd = `$(Base.julia_cmd()) --startup-file=no -e $torun`
try
challenge_prompt(cmd, challenges)
return open(output_file, "r") do fp
deserialize(fp)
end
finally
isfile(output_file) && rm(output_file)
isfile(input_code) && rm(input_code)
end
return nothing
end

function challenge_prompt(cmd::Cmd, challenges)
function format_output(output)
str = read(seekstart(output), String)
isempty(str) && return ""
return "Process output found:\n\"\"\"\n$str\n\"\"\""
end
out = IOBuffer()
with_fake_pty() do pts, ptm
p = run(detach(cmd), pts, pts, pts, wait=false) # getpass uses stderr by default
Base.close_stdio(pts)

# Kill the process if it takes too long. Typically occurs when process is waiting
# for input.
timer = Channel{Symbol}(1)
watcher = @async begin
waited = 0
while waited < timeout && process_running(p)
sleep(1)
waited += 1
end

if process_running(p)
kill(p)
put!(timer, :timeout)
elseif success(p)
put!(timer, :success)
else
put!(timer, :failure)
end

# SIGKILL stubborn processes
if process_running(p)
sleep(3)
process_running(p) && kill(p, Base.SIGKILL)
end
wait(p)
end

wroteall = false
try
for (challenge, response) in challenges
write(out, readuntil(ptm, challenge, keep=true))
if !isopen(ptm)
error("Could not locate challenge: \"$challenge\". ",
format_output(out))
end
write(ptm, response)
end
wroteall = true

# Capture output from process until `pts` is closed
write(out, ptm)
catch ex
if !(wroteall && ex isa Base.IOError && ex.code == Base.UV_EIO)
# ignore EIO from `ptm` after `pts` dies
error("Process failed possibly waiting for a response. ",
format_output(out))
end
end

status = fetch(timer)
close(ptm)
if status !== :success
if status === :timeout
error("Process timed out possibly waiting for a response. ",
format_output(out))
else
error("Failed process. ", format_output(out), "\n", p)
end
end
wait(watcher)
end
nothing
end

end
1 change: 0 additions & 1 deletion test/testhelpers/FakePTYs.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# This file is a part of Julia. License is MIT: https://julialang.org/license

module FakePTYs

if Sys.iswindows()
Expand Down

0 comments on commit b79856e

Please sign in to comment.