From 7191a62bb98aa6d9832bec96606bb96a23776bca Mon Sep 17 00:00:00 2001 From: Keno Fischer Date: Thu, 26 Jun 2025 02:55:40 +0000 Subject: [PATCH 1/3] Add ca_root_locations() API to properly handle SSL_CERT_FILE and SSL_CERT_DIR - Add new ca_root_locations() function that returns (files, directories) tuple - ca_root_locations() takes allow_nothing parameter like the deprecated functions - SSL_CERT_FILE is now treated as a single file path (not delimiter-separated) - SSL_CERT_DIR supports delimiter-separated list of directories using keepempty=false - Add deprecation warnings to ca_roots() and ca_roots_path() functions - Simplify _ca_roots() implementation and system CA root locations handling - System CA roots are always files, not directories - Use withenv in tests for proper environment isolation - Add comprehensive tests for the new functionality Fixes #41 --- src/ca_roots.jl | 112 ++++++++++++++++++++++++++++----- test/runtests.jl | 160 ++++++++++++++++++++++++++++++++++++++++++----- test/setup.jl | 2 +- 3 files changed, 241 insertions(+), 33 deletions(-) diff --git a/src/ca_roots.jl b/src/ca_roots.jl index b198f3e..20eddeb 100644 --- a/src/ca_roots.jl +++ b/src/ca_roots.jl @@ -1,4 +1,4 @@ -export ca_roots, ca_roots_path +export ca_roots, ca_roots_path, ca_root_locations """ ca_roots() :: Union{Nothing, String} @@ -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 @@ -48,7 +51,89 @@ 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 + 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. @@ -98,17 +183,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 diff --git a/test/runtests.jl b/test/runtests.jl index 3aed8a9..00f32a3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,42 +3,170 @@ 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 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 diff --git a/test/setup.jl b/test/setup.jl index 99d4fef..ff86e8e 100644 --- a/test/setup.jl +++ b/test/setup.jl @@ -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__) From 4d105cd6cab820e82ff6ca57ac07a14cdb1b872b Mon Sep 17 00:00:00 2001 From: Keno Fischer Date: Thu, 26 Jun 2025 03:58:06 +0000 Subject: [PATCH 2/3] Fix ca_root_locations to return nothing on Windows/macOS when JULIA_SSL_CA_ROOTS_PATH="" When JULIA_SSL_CA_ROOTS_PATH is set to empty string, it should return nothing on Windows/macOS (to use system certificates) when allow_nothing is true, not the bundled certificates. --- src/ca_roots.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ca_roots.jl b/src/ca_roots.jl index 20eddeb..84b0546 100644 --- a/src/ca_roots.jl +++ b/src/ca_roots.jl @@ -84,7 +84,10 @@ function ca_root_locations(; allow_nothing::Bool=true)::Union{Nothing, Tuple{Vec # 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 + # 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 From 3c963363ce32d74e0e2304485e24a8f9cd10219d Mon Sep 17 00:00:00 2001 From: Keno Fischer Date: Thu, 26 Jun 2025 04:04:55 +0000 Subject: [PATCH 3/3] Add test for JULIA_SSL_CA_ROOTS_PATH set to directory This improves test coverage by testing the case where JULIA_SSL_CA_ROOTS_PATH is set to a directory path, which triggers the isdir() branch in ca_root_locations(). --- test/runtests.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/runtests.jl b/test/runtests.jl index 00f32a3..aec2626 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -89,6 +89,19 @@ include("setup.jl") @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",