@@ -579,47 +579,46 @@ def normalize_query(query)
579579 end . join ( "&" )
580580 end
581581
582- # Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input
583- # buffer one segment at a time, popping the previous output segment
584- # whenever a `..` is encountered, so that `/api/../mcp` collapses to
585- # `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
582+ # Implements RFC 3986 Section 5.2.4 `remove_dot_segments` over the path's segments, so that `/api/../mcp` collapses to
583+ # `/mcp` and `/foo/./bar` collapses to `/foo/bar`. Each segment is visited once: the RFC's buffer rewriting,
584+ # applied literally, copies the remaining input for every dot segment, and the path is the server's to choose.
585+ #
586+ # The output matches the RFC's algorithm for a relative path as well, including its quirk that a `..` popping
587+ # the first segment leaves the result absolute (`a/../b` becomes `/b`), although `URI#path` never hands over a relative path.
588+ # The rule letters below are the RFC's own: steps A through E of its loop.
586589 # https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
587590 def remove_dot_segments ( path )
588591 return path if path . nil? || path . empty?
589592
590- input = path . dup
591- output = +""
592- until input . empty?
593- if input . start_with? ( "../" )
594- input = input [ 3 ..]
595- elsif input . start_with? ( "./" )
596- input = input [ 2 ..]
597- elsif input . start_with? ( "/./" )
598- input = "/#{ input [ 3 ..] } "
599- elsif input == "/."
600- input = "/"
601- elsif input . start_with? ( "/../" )
602- input = "/#{ input [ 4 ..] } "
603- output = remove_last_segment ( output )
604- elsif input == "/.."
605- input = "/"
606- output = remove_last_segment ( output )
607- elsif input == "." || input == ".."
608- input = ""
593+ absolute = path . start_with? ( "/" )
594+ segments = path . split ( "/" , -1 )
595+ segments . shift if absolute
596+
597+ output = [ ]
598+
599+ # True until a segment other than `.` or `..` is kept: a relative path's leading `./` and `../` are dropped together with
600+ # the slash after them (Rule A), so the segment that follows is still the slash-less first one.
601+ leading = !absolute
602+ segments . each_with_index do |segment , index |
603+ last = index == segments . length - 1
604+
605+ # Rule A, and Rule D for a path that is nothing but `.` or `..`.
606+ next if leading && ( segment == "." || segment == ".." )
607+
608+ if segment == "."
609+ # Rule B: `/./` disappears; a final `/.` leaves its slash behind.
610+ output << "/" if last
611+ elsif segment == ".."
612+ # Rule C: `/../` removes the previous segment; a final `/..` leaves its slash behind.
613+ output . pop
614+ output << "/" if last
609615 else
610- segment = input . match ( %r{\A /?[^/]*} ) [ 0 ]
611- output << segment
612- input = input [ segment . length ..]
616+ # Rule E: the first segment of a relative path carries no slash.
617+ output << ( leading ? segment : "/#{ segment } " )
613618 end
619+ leading = false
614620 end
615- output
616- end
617-
618- def remove_last_segment ( output )
619- idx = output . rindex ( "/" )
620- return +"" if idx . nil?
621-
622- output [ 0 ...idx ]
621+ output . join
623622 end
624623 end
625624 end
0 commit comments