From 54321ae8bd91fcfd415cca45ca408041c50d18e2 Mon Sep 17 00:00:00 2001 From: Oskar Eichler Date: Fri, 28 Aug 2026 04:59:23 +0300 Subject: [PATCH 1/3] Decode SCP URI components and remove IPv6 address brackets --- lib/uri/open-scp.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/uri/open-scp.rb b/lib/uri/open-scp.rb index 086fbe7..fb97ccd 100644 --- a/lib/uri/open-scp.rb +++ b/lib/uri/open-scp.rb @@ -8,9 +8,9 @@ module URI class SCP def buffer_open(buf, proxy, open_options) - options = open_options.merge(:port => port, :password => password) + options = open_options.merge(:port => port, :password => password && parser.unescape(password)) progress = options.delete(:progress_proc) - buf << Net::SCP.download!(host, user, path, nil, options, &progress) + buf << Net::SCP.download!(hostname, user && parser.unescape(user), parser.unescape(path), nil, options, &progress) buf.io.rewind end From 48166af2a5d0555f80aec836fd65ecc2aea49155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 23 Oct 2026 00:24:57 +0200 Subject: [PATCH 2/3] Add tests and changelog for decoding SCP URI components --- changelog.d/94.change.txt | 1 + test/test_open_scp.rb | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 changelog.d/94.change.txt create mode 100644 test/test_open_scp.rb diff --git a/changelog.d/94.change.txt b/changelog.d/94.change.txt new file mode 100644 index 0000000..981fc08 --- /dev/null +++ b/changelog.d/94.change.txt @@ -0,0 +1 @@ +Opening an `scp://` URI through `uri/open-scp` now percent-decodes the user, password and path (once, keeping a literal `+`) and passes an IPv6 address to SSH without its brackets, so `scp://user@[::1]/a%20b` downloads `/a b` from `::1`. Thanks @OskarEichler [#94] diff --git a/test/test_open_scp.rb b/test/test_open_scp.rb new file mode 100644 index 0000000..3736405 --- /dev/null +++ b/test/test_open_scp.rb @@ -0,0 +1,52 @@ +require 'common' +require 'uri/open-scp' + +class TestOpenSCP < Net::SCP::TestCase + # Stands in for Net::SCP#download! of a single file, reporting progress the way Net::SCP::Download does. + class FakeSCP + attr_reader :remote, :local, :options + attr_accessor :after_write + + def initialize(chunks) + @chunks = chunks + end + + def download!(remote, local = nil, options = {}, &progress) + @remote = remote + @local = local + @options = options + io = local || StringIO.new(String.new) + total = @chunks.sum(&:bytesize) + sent = 0 + progress&.call(:channel, File.basename(remote), sent, total) + @chunks.each do |chunk| + io.write(chunk) + after_write&.call + sent += chunk.bytesize + progress&.call(:channel, File.basename(remote), sent, total) + end + local ? true : io.string + end + end + + def stub_scp(*chunks) + fake = FakeSCP.new(chunks) + Net::SCP.expects(:start).with { |*args| @start_args = args }.yields(fake) + fake + end + + def test_ipv6_host_is_passed_without_brackets + stub_scp("data") + URI("scp://user@[::1]:2222/path").open(&:read) + assert_equal ["::1", "user"], @start_args.first(2) + end + + def test_user_password_and_path_are_percent_decoded_once + fake = stub_scp("data") + URI("scp://us%65r%2Bx:p%40ss%3Aw+rd@example.com/dir%2Fsub/a%20b+c%2541%C3%A9").open(&:read) + assert_equal ["example.com", "user+x"], @start_args.first(2) + assert_equal "p@ss:w+rd", fake.options[:password] + assert_equal "/dir/sub/a b+c%41é", fake.remote + assert_equal Encoding::UTF_8, fake.remote.encoding + end +end From 535f31a020b202476151d2e7b7cfd35530a63d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 23 Oct 2026 01:46:57 +0200 Subject: [PATCH 3/3] Decode URI components with URI::DEFAULT_PARSER URI::Generic#parser is the RFC 3986 parser, which only has #unescape since Ruby 3.4. --- lib/uri/open-scp.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/uri/open-scp.rb b/lib/uri/open-scp.rb index fb97ccd..c5e6b38 100644 --- a/lib/uri/open-scp.rb +++ b/lib/uri/open-scp.rb @@ -8,9 +8,9 @@ module URI class SCP def buffer_open(buf, proxy, open_options) - options = open_options.merge(:port => port, :password => password && parser.unescape(password)) + options = open_options.merge(:port => port, :password => password && URI::DEFAULT_PARSER.unescape(password)) progress = options.delete(:progress_proc) - buf << Net::SCP.download!(hostname, user && parser.unescape(user), parser.unescape(path), nil, options, &progress) + buf << Net::SCP.download!(hostname, user && URI::DEFAULT_PARSER.unescape(user), URI::DEFAULT_PARSER.unescape(path), nil, options, &progress) buf.io.rewind end