Skip to content

Add ca_root_locations() API to properly handle SSL_CERT_{FILE,DIR} #42

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
115 changes: 99 additions & 16 deletions src/ca_roots.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export ca_roots, ca_roots_path
export ca_roots, ca_roots_path, ca_root_locations

"""
ca_roots() :: Union{Nothing, String}
Expand All @@ -21,7 +21,10 @@ of these variables that is set (whether the path exists or not). If
are ignored (as if unset); if the other variables are set to the empty string,
they behave is if they are not set.
"""
ca_roots()::Union{Nothing,String} = _ca_roots(true)
function ca_roots()::Union{Nothing,String}
Base.depwarn("`ca_roots()` is deprecated. Use `ca_root_locations()` instead.", :ca_roots)
return _ca_roots(true)
end

"""
ca_roots_path() :: String
Expand All @@ -48,7 +51,92 @@ of these variables that is set (whether the path exists or not). If
are ignored (as if unset); if the other variables are set to the empty string,
they behave is if they are not set.
"""
ca_roots_path()::String = _ca_roots(false)
function ca_roots_path()::String
Base.depwarn("`ca_roots_path()` is deprecated. Use `ca_root_locations(allow_nothing=false)` instead.", :ca_roots_path)
return _ca_roots(false)
end

"""
ca_root_locations(; allow_nothing::Bool=true) :: Union{Nothing, Tuple{Vector{String}, Vector{String}}}

The `ca_root_locations()` function returns certificate locations for the current system.

If `allow_nothing` is `true` (default), returns `nothing` on systems like Windows and macOS
where the built-in TLS engines know how to verify hosts using the system's built-in
certificate verification mechanism.

Otherwise, returns a tuple of two vectors: (files, directories). The first vector contains
paths to certificate files, and the second vector contains paths to certificate directories.
SSL_CERT_FILE specifies a single certificate file, while SSL_CERT_DIR can contain a
delimiter-separated list of directories.

The paths are determined by checking the following environment variables in order:
1. `JULIA_SSL_CA_ROOTS_PATH` - If set, other variables are ignored
2. `SSL_CERT_FILE` - Path to a single certificate file
3. `SSL_CERT_DIR` - Delimiter-separated list of certificate directories

If no environment variables are set, system default locations are returned.
"""
function ca_root_locations(; allow_nothing::Bool=true)::Union{Nothing, Tuple{Vector{String}, Vector{String}}}
files = String[]
dirs = String[]

# Check for JULIA_SSL_CA_ROOTS_PATH first
julia_path = get(ENV, "JULIA_SSL_CA_ROOTS_PATH", nothing)
if julia_path == ""
# Empty string means ignore other variables and use system defaults
if allow_nothing && (Sys.iswindows() || Sys.isapple())
return nothing
end
return _system_ca_root_locations()
elseif julia_path !== nothing
# JULIA_SSL_CA_ROOTS_PATH is set, determine if it's a file or directory
if isdir(julia_path)
push!(dirs, julia_path)
else
push!(files, julia_path)
end
return (files, dirs)
end

# Parse SSL_CERT_FILE (single file path)
cert_file = get(ENV, "SSL_CERT_FILE", "")
if !isempty(cert_file)
push!(files, cert_file)
end

# Parse SSL_CERT_DIR
cert_dir = get(ENV, "SSL_CERT_DIR", "")
if !isempty(cert_dir)
delimiter = Sys.iswindows() ? ';' : ':'
append!(dirs, split(cert_dir, delimiter; keepempty=false))
end

# If no environment variables were set, check system defaults
if isempty(files) && isempty(dirs)
# If on Windows/macOS and allow_nothing is true, return nothing
if allow_nothing && (Sys.iswindows() || Sys.isapple())
return nothing
end
return _system_ca_root_locations()
end

return (files, dirs)
end

# Helper function to get system default certificate locations
function _system_ca_root_locations()::Tuple{Vector{String}, Vector{String}}
files = String[]
dirs = String[]

# System CA roots are always files, not directories
root = system_ca_roots()
if root !== nothing
push!(files, root)
end

return (files, dirs)
end

# NOTE: this has to be a function not a constant since the
# value of Sys.BINDIR changes from build time to run time.
Expand Down Expand Up @@ -98,17 +186,12 @@ const CA_ROOTS_VARS = [
]

function _ca_roots(allow_nothing::Bool)
for var in CA_ROOTS_VARS
path = get(ENV, var, nothing)
if path == "" && startswith(var, "JULIA_")
break # ignore other vars
end
if !isempty(something(path, ""))
return path
end
end
if Sys.iswindows() || Sys.isapple()
allow_nothing && return # use system certs
end
return system_ca_roots()
result = ca_root_locations(; allow_nothing)
result === nothing && return nothing

files, dirs = result
# Prioritize files over directories for backward compatibility
!isempty(files) && return first(files)
@assert !isempty(dirs) "Should always have at least the bundled CA roots"
return first(dirs)
end
173 changes: 157 additions & 16 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,183 @@ include("setup.jl")
@testset "ca_roots" begin
@testset "system certs" begin
@test isfile(bundled_ca_roots())
@test ca_roots_path() isa String
@test ispath(ca_roots_path())
@test_deprecated ca_roots_path() isa String
@test_deprecated ispath(ca_roots_path())
if Sys.iswindows() || Sys.isapple()
@test ca_roots_path() == bundled_ca_roots()
@test ca_roots() === nothing
@test_deprecated ca_roots_path() == bundled_ca_roots()
@test_deprecated ca_roots() === nothing
else
@test ca_roots_path() != bundled_ca_roots()
@test ca_roots() == ca_roots_path()
@test_deprecated ca_roots_path() != bundled_ca_roots()
@test_deprecated ca_roots() == ca_roots_path()
end
end

@testset "env vars" begin
unset = ca_roots(), ca_roots_path()
unset = @test_deprecated((ca_roots(), ca_roots_path()))
value = "Why hello!"
# set only one CA_ROOT_VAR
for var in CA_ROOTS_VARS
ENV[var] = value
@test ca_roots() == value
@test ca_roots_path() == value
@test_deprecated ca_roots() == value
@test_deprecated ca_roots_path() == value
ENV[var] = ""
@test ca_roots() == unset[1]
@test ca_roots_path() == unset[2]
@test_deprecated ca_roots() == unset[1]
@test_deprecated ca_roots_path() == unset[2]
clear_env()
end
# set multiple CA_ROOT_VARS with increasing precedence
ENV["SSL_CERT_DIR"] = "3"
@test ca_roots() == ca_roots_path() == "3"
@test_deprecated ca_roots() == ca_roots_path() == "3"
ENV["SSL_CERT_FILE"] = "2"
@test ca_roots() == ca_roots_path() == "2"
@test_deprecated ca_roots() == ca_roots_path() == "2"
ENV["JULIA_SSL_CA_ROOTS_PATH"] = "1"
@test ca_roots() == ca_roots_path() == "1"
@test_deprecated ca_roots() == ca_roots_path() == "1"
ENV["JULIA_SSL_CA_ROOTS_PATH"] = ""
@test ca_roots() == unset[1]
@test ca_roots_path() == unset[2]
@test_deprecated ca_roots() == unset[1]
@test_deprecated ca_roots_path() == unset[2]
clear_env()
end

@testset "ca_root_locations" begin
path_sep = Sys.iswindows() ? ';' : ':'

# Test with no environment variables set
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => nothing,
"SSL_CERT_DIR" => nothing) do
# Test with allow_nothing=true (default)
result = ca_root_locations()
if Sys.iswindows() || Sys.isapple()
@test result === nothing
else
# On Unix systems, check system locations
@test result !== nothing
files, dirs = result
root = system_ca_roots()
if root !== nothing
@test files == [root]
@test isempty(dirs)
end
end

# Test with allow_nothing=false
result = ca_root_locations(; allow_nothing=false)
@test result !== nothing
files, dirs = result
if Sys.iswindows() || Sys.isapple()
@test files == [bundled_ca_roots()]
@test isempty(dirs)
else
root = system_ca_roots()
if root !== nothing
@test files == [root]
@test isempty(dirs)
end
end
end

# Test with JULIA_SSL_CA_ROOTS_PATH set to a file
withenv("JULIA_SSL_CA_ROOTS_PATH" => "/path/to/cert.pem",
"SSL_CERT_FILE" => nothing,
"SSL_CERT_DIR" => nothing) do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test files == ["/path/to/cert.pem"]
@test isempty(dirs)
end

# Test with JULIA_SSL_CA_ROOTS_PATH set to a directory
mktempdir() do tempdir
withenv("JULIA_SSL_CA_ROOTS_PATH" => tempdir,
"SSL_CERT_FILE" => nothing,
"SSL_CERT_DIR" => nothing) do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test isempty(files)
@test dirs == [tempdir]
end
end

# Test with JULIA_SSL_CA_ROOTS_PATH set to empty string
withenv("JULIA_SSL_CA_ROOTS_PATH" => "",
"SSL_CERT_FILE" => "/ignored/cert.pem",
"SSL_CERT_DIR" => nothing) do
result = ca_root_locations()
# Should ignore other variables and return system defaults
if Sys.iswindows() || Sys.isapple()
@test result === nothing
else
@test result !== nothing
files, dirs = result
root = system_ca_roots()
if root !== nothing
@test files == [root]
@test isempty(dirs)
end
end
end

# Test with SSL_CERT_FILE (single path)
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => "/path1/cert.pem",
"SSL_CERT_DIR" => nothing) do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test files == ["/path1/cert.pem"]
@test isempty(dirs)
end

# Test that SSL_CERT_FILE with delimiter is treated as single path
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => "/path1/cert.pem$(path_sep)/path2/cert.pem",
"SSL_CERT_DIR" => nothing) do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test files == ["/path1/cert.pem$(path_sep)/path2/cert.pem"]
@test isempty(dirs)
end

# Test with SSL_CERT_DIR containing multiple paths
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => nothing,
"SSL_CERT_DIR" => "/certs1$(path_sep)/certs2") do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test isempty(files)
@test dirs == ["/certs1", "/certs2"]
end

# Test with both SSL_CERT_FILE and SSL_CERT_DIR
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => "/cert1.pem",
"SSL_CERT_DIR" => "/certs1$(path_sep)/certs2") do
result = ca_root_locations()
@test result !== nothing
files, dirs = result
@test files == ["/cert1.pem"]
@test dirs == ["/certs1", "/certs2"]
end

# Test that ca_roots() uses ca_root_locations() correctly
# Priority should be files over directories
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => "/cert.pem",
"SSL_CERT_DIR" => "/certs") do
@test_deprecated ca_roots() == "/cert.pem"
end

# Test with only SSL_CERT_DIR
withenv("JULIA_SSL_CA_ROOTS_PATH" => nothing,
"SSL_CERT_FILE" => nothing,
"SSL_CERT_DIR" => "/certs") do
@test_deprecated ca_roots() == "/certs"
end
end
end

@testset "ssh_options" begin
Expand Down
2 changes: 1 addition & 1 deletion test/setup.jl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Test
using Logging
using NetworkOptions
using NetworkOptions: CA_ROOTS_VARS, bundled_ca_roots, bundled_known_hosts
using NetworkOptions: CA_ROOTS_VARS, bundled_ca_roots, bundled_known_hosts, system_ca_roots

const pkg_dir = dirname(@__DIR__)

Expand Down
Loading