diff --git a/lib/localhost/authority.rb b/lib/localhost/authority.rb index ddfb3bb..41c09ac 100644 --- a/lib/localhost/authority.rb +++ b/lib/localhost/authority.rb @@ -17,8 +17,27 @@ module Localhost class Authority # Where to store the key pair on the filesystem. This is a subdirectory # of $XDG_STATE_HOME, or ~/.local/state/ when that's not defined. - def self.path - File.expand_path("localhost.rb", ENV.fetch("XDG_STATE_HOME", "~/.local/state")) + # + # Ensures that the directory to store the certificate exists. If the legacy + # directory (~/.localhost/) exists, it is moved into the new XDG Basedir + # compliant directory. + # + # After May 2025, the old_root option may be removed. + def self.path(env = ENV, old_root: nil) + path = File.expand_path("localhost.rb", env.fetch("XDG_STATE_HOME", "~/.local/state")) + + unless File.directory?(path) + FileUtils.mkdir_p(path, mode: 0700) + end + + # Migrates the legacy dir ~/.localhost/ to the XDG compliant directory + old_root ||= File.expand_path("~/.localhost") + if File.directory?(old_root) + FileUtils.mv(Dir.glob(File.join(old_root, "*")), path, force: true) + FileUtils.rmdir(old_root) + end + + return path end # List all certificate authorities in the given directory: @@ -180,8 +199,6 @@ def client_context(*args) end def load(path = @root) - ensure_authority_path_exists(path) - certificate_path = File.join(path, "#{@hostname}.crt") key_path = File.join(path, "#{@hostname}.key") @@ -200,8 +217,6 @@ def load(path = @root) end def save(path = @root) - ensure_authority_path_exists(path) - lockfile_path = File.join(path, "#{@hostname}.lock") File.open(lockfile_path, File::RDWR|File::CREAT, 0644) do |lockfile| @@ -218,20 +233,5 @@ def save(path = @root) ) end end - - # Ensures that the directory to store the certificate exists. If the legacy - # directory (~/.localhost/) exists, it is moved into the new XDG Basedir - # compliant directory. - # - # After May 2025, this method should be removed as the legacy directory - # will no longer be contain valid certificates. - def ensure_authority_path_exists(path = @root) - old_root = File.expand_path("~/.localhost") - - FileUtils.mkdir_p(path, mode: 0700) unless File.directory?(path) - - # Migrates the legacy dir ~/.localhost/ to the XDG compliant directory - FileUtils.mv("#{@old_root}/.", path, force: true) if File.directory?(old_root) - end end end diff --git a/test/localhost/authority.rb b/test/localhost/authority.rb index dfe1799..55ed231 100644 --- a/test/localhost/authority.rb +++ b/test/localhost/authority.rb @@ -17,30 +17,42 @@ require 'tempfile' describe Localhost::Authority do - def before - @old_root = File.expand_path("~/.localhost") - @old_root_exists = File.directory?(@old_root) - - if @old_root_exists - @tmp_folder = File.expand_path("~/.localhost_test") - FileUtils.mkdir_p(@tmp_folder, mode: 0700) - FileUtils.cp_r("#{@old_root}/.", @tmp_folder) + def around + Dir.mktmpdir do |path| + @root = path + + yield + ensure + @root = nil end end - - def after - if @old_root_exists - FileUtils.mkdir_p(@old_root, mode: 0700) - FileUtils.mv("#{@tmp_folder}/.", @old_root, force: true) - FileUtils.rm_r(@tmp_folder) + + let(:authority) {subject.new("localhost", root: @root)} + + with ".path" do + it "uses XDG_STATE_HOME" do + env = {'XDG_STATE_HOME' => @root} + + expect(Localhost::Authority.path(env)).to be == File.expand_path("localhost.rb", @root) + end + + it "copies legacy directory" do + xdg_state_home = File.join(@root, ".local", "state") + env = {'XDG_STATE_HOME' => xdg_state_home} + + old_root = File.join(@root, ".localhost") + Dir.mkdir(old_root) + File.write(File.join(old_root, "localhost.crt"), "*fake certificate*") + File.write(File.join(old_root, "localhost.key"), "*fake key*") + + path = Localhost::Authority.path(env, old_root: old_root) + expect(path).to be == File.expand_path("localhost.rb", xdg_state_home) + expect(File).to be(:exist?, File.expand_path("localhost.crt", path)) + expect(File).to be(:exist?, File.expand_path("localhost.key", path)) + + expect(File).not.to be(:exist?, old_root) end end - - let(:xdg_dir) { File.join(Dir.pwd, "state") } - let(:authority) { - ENV["XDG_STATE_HOME"] = xdg_dir - subject.new - } with '#certificate' do it "is not valid for more than 1 year" do @@ -52,35 +64,15 @@ def after end end - it "can generate key and certificate" do - Dir.mktmpdir('localhost') do |dir| - authority.save(dir) - - expect(File).to be(:exist?, File.expand_path("localhost.lock", dir)) - expect(File).to be(:exist?, File.expand_path("localhost.crt", dir)) - expect(File).to be(:exist?, File.expand_path("localhost.key", dir)) - end - end - it "have correct key and certificate path" do - authority.save(authority.class.path) - expect(File).to be(:exist?, authority.certificate_path) - expect(File).to be(:exist?, authority.key_path) + authority.save - expect(authority.key_path).to be == File.join(xdg_dir, "localhost.rb", "localhost.key") - expect(authority.certificate_path).to be == File.join(xdg_dir, "localhost.rb", "localhost.crt") - end - - it "properly falls back when XDG_STATE_HOME is not set" do - ENV.delete("XDG_STATE_HOME") - authority = subject.new - - authority.save(authority.class.path) expect(File).to be(:exist?, authority.certificate_path) expect(File).to be(:exist?, authority.key_path) - expect(authority.key_path).to be == File.join(File.expand_path("~/.local/state/"), "localhost.rb", "localhost.key") - expect(authority.certificate_path).to be == File.join(File.expand_path("~/.local/state/"), "localhost.rb", "localhost.crt") + expect(File).to be(:exist?, File.expand_path("localhost.lock", @root)) + expect(File).to be(:exist?, File.expand_path("localhost.crt", @root)) + expect(File).to be(:exist?, File.expand_path("localhost.key", @root)) end with '#store' do @@ -94,40 +86,4 @@ def after expect(authority.server_context).to be_a OpenSSL::SSL::SSLContext end end - - with 'client/server' do - include Sus::Fixtures::Async::ReactorContext - - let(:endpoint) {Async::IO::Endpoint.tcp("localhost", 4040)} - let(:server_endpoint) {Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.server_context)} - let(:client_endpoint) {Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.client_context)} - - let(:client) {client_endpoint.connect} - - def before - @bound_endpoint = Async::IO::SharedEndpoint.bound(server_endpoint) - - @server_task = reactor.async do - @bound_endpoint.accept do |peer| - peer.write("Hello World!") - peer.close - end - end - - super - end - - def after - @server_task&.stop - @bound_endpoint&.close - - super - end - - it "can verify peer" do - expect(client.read(12)).to be == "Hello World!" - - client.close - end - end end diff --git a/test/localhost/protocol.rb b/test/localhost/protocol.rb index 9df9f01..8fd8b01 100644 --- a/test/localhost/protocol.rb +++ b/test/localhost/protocol.rb @@ -7,10 +7,6 @@ require 'sus/fixtures/async/http/server_context' -require 'async/io/host_endpoint' -require 'async/io/ssl_endpoint' -require 'async/io/shared_endpoint' - require 'async/process' require 'fileutils' @@ -57,4 +53,13 @@ def make_client_endpoint(bound_endpoint) it_behaves_like AValidProtocol, "default", [], [] it_behaves_like AValidProtocol, "TLSv1.2", ["-tls1_2"], ["--tlsv1.2"] it_behaves_like AValidProtocol, "TLSv1.3", ["-tls1_3"], ["--tlsv1.3"] + + it "can connect using HTTPS" do + response = client.get("/") + + expect(response).to be(:success?) + ensure + response&.finish + client.close + end end