diff --git a/lib/uri/generic.rb b/lib/uri/generic.rb index 6a0f638..fce8a8a 100644 --- a/lib/uri/generic.rb +++ b/lib/uri/generic.rb @@ -1021,9 +1021,21 @@ def merge_path(base, rel) # RFC2396, Section 5.2, 6), a) base_path << '' if base_path.last == '..' - while i = base_path.index('..') - base_path.slice!(i - 1, 2) + # Remove "/.." pairs in a single left-to-right pass (O(n)). + # This deliberately differs from the relative-path stack handling + # below: a leading ".." (one with no preceding segment left to + # cancel) discards the whole base path, reproducing the previous + # index/slice! implementation exactly. + reduced = [] + base_path.each do |seg| + if seg == '..' + break if reduced.empty? + reduced.pop + else + reduced << seg + end end + base_path = reduced if (first = rel_path.first) and first.empty? base_path.clear diff --git a/test/uri/test_generic.rb b/test/uri/test_generic.rb index 94eea71..9006125 100644 --- a/test/uri/test_generic.rb +++ b/test/uri/test_generic.rb @@ -278,6 +278,43 @@ def test_merge assert_equal(u0, u1) end + def test_merge_path_dot_dot_removal + # Base-path ".." removal (RFC2396 5.2 6a) is handled by a single + # left-to-right pass. These lock in the exact, historically observed + # semantics, which differ from the relative-path stack: a leading ".." + # (or a ".." exposed as leading after earlier cancellations) discards + # the remaining base path rather than being kept. + { + 'http://h/a/../../b' => { 'x' => 'http://h/x' }, + 'http://h/../a' => { 'x' => 'http://h/x' }, + 'http://h/a/..' => { 'x' => 'http://h/x' }, + 'http://h/../x' => { 'y' => 'http://h/y' }, + 'http://h/foo/bar/..' => { './' => 'http://h/foo/' }, + 'http://h/foo/bar/../..' => { './' => 'http://h/' }, + 'http://h/a/b/c' => { + '../../g' => 'http://h/g', + '../../../g' => 'http://h/g', + '../../../../g' => 'http://h/g', + }, + 'http://h/p//q/..' => { 'r' => 'http://h/p//r' }, + 'http://h/a/../..//y' => { 'z' => 'http://h/z' }, + }.each { |base, map| + map.each { |rel, expected| + assert_equal(expected, URI.parse(base).merge(rel).to_s, + "<#{base}> + #{rel.inspect}") + } + } + end + + def test_merge_path_dot_dot_removal_is_linear + # Regression guard for the previous O(n^2) base-path ".." removal: + # merging a base full of "a/../" segments must scale linearly. + pre = ->(n) {URI.parse('http://example.com/' + 'a/../' * n)} + assert_linear_performance((1..5).map {|i| 10 ** i}, pre: pre) do |base| + assert_equal('http://example.com/x', base.merge('x').to_s) + end + end + def test_merge_authority u = URI.parse('http://user:pass@example.com:8080') u0 = URI.parse('http://new.example.org/path')