diff --git a/lib/nack.rb b/lib/nack.rb index 6cfe10f..370807a 100644 --- a/lib/nack.rb +++ b/lib/nack.rb @@ -4,5 +4,6 @@ require_relative "nack/error" require_relative "nack/headers" require_relative "nack/mime" +require_relative "nack/query_parser" require_relative "nack/spec" require_relative "nack/utils" diff --git a/lib/nack/query_parser.rb b/lib/nack/query_parser.rb new file mode 100644 index 0000000..7188860 --- /dev/null +++ b/lib/nack/query_parser.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Nack + class QueryParser + def initialize(param_limit: 4096, depth_limit: 32) + @param_limit = param_limit + @depth_limit = depth_limit + freeze + end + + def parse_query(string) + params = [] + each_param(string) { |name, value| params << [name, value] } + params + end + + def parse_nested_query(string) + params = {} + each_param(string) do |name, value| + keys = split_name(name) + raise ParamError, "too deeply nested: #{name.inspect}" if keys.size > @depth_limit + store(params, keys, value, name) + end + params + end + + def build_query(params) + params.map { |name, value| build_param(Utils.escape(name), value) }.join("&") + end + + def build_nested_query(params) + join(params.map { |name, value| build_nested_param(Utils.escape(name), value) }) + end + + private + + def each_param(string) + pairs = string.b.split("&", @param_limit + 1) + raise ParamError, "too many parameters" if pairs.size > @param_limit + pairs.each do |pair| + next if pair.empty? + name, value = pair.split("=", 2) + yield Utils.unescape(name), value ? Utils.unescape(value) : +"" + end + end + + def split_name(name) + start = name.index("[") + return [name] unless start && /\A[^\[\]]+(?:\[[^\[\]]*\])*\z/.match?(name) + inner = name[start + 1...-1] + inner.empty? ? [name[0, start], ""] : [name[0, start], *inner.split("][", -1)] + end + + def store(container, keys, value, name) + key, *rest = keys + if rest.empty? + return container << value if container.is_a?(Array) + raise ParamError, "conflicting shapes: #{name.inspect}" if container[key] in Array | Hash + return container[key] = value + end + type = rest.first.empty? ? Array : Hash + if container.is_a?(Array) + container << type.new unless container.last.instance_of?(type) && !occupied?(container.last, rest) + child = container.last + else + child = container.fetch(key) { container[key] = type.new } + raise ParamError, "conflicting shapes: #{name.inspect}" unless child.instance_of?(type) + end + store(child, rest, value, name) + end + + def occupied?(container, keys) + case container + when Array then false + when Hash + key, *rest = keys + container.key?(key) && (rest.empty? || occupied?(container[key], rest)) + else true + end + end + + def build_param(escaped_name, value) + "#{escaped_name}=#{Utils.escape(value)}" + end + + def build_nested_param(escaped_name, value) + case value + when Array then join(value.map { build_nested_param("#{escaped_name}%5B%5D", it) }) + when Hash then join(value.map { |key, child| build_nested_param("#{escaped_name}%5B#{Utils.escape(key)}%5D", child) }) + else build_param(escaped_name, value) + end + end + + def join(parts) + parts.reject(&:empty?).join("&") + end + end +end diff --git a/test/spec_query_parser.rb b/test/spec_query_parser.rb new file mode 100644 index 0000000..0b285cd --- /dev/null +++ b/test/spec_query_parser.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "helper" + +describe Nack::QueryParser do + let(:parser) { Nack::QueryParser.new } + + it "is shareable across Ractors" do + Ractor.shareable?(parser).must_equal true + end + + it "parses a query per WHATWG" do + parser.parse_query("a=1&b=2&a=3").must_equal [["a", "1"], ["b", "2"], ["a", "3"]] + parser.parse_query("&").must_equal [] + parser.parse_query("a;b").must_equal [["a;b", ""]] + parser.parse_query("a==").must_equal [["a", "="]] + parser.parse_query("a").must_equal [["a", ""]] + parser.parse_query("+=+").must_equal [[" ", " "]] + parser.parse_query("\xff").must_equal [["\uFFFD", ""]] + end + + it "raises ParamError beyond the parameter limit" do + limited = Nack::QueryParser.new(param_limit: 1) + limited.parse_query("a").must_equal [["a", ""]] + -> { limited.parse_query("a&b") }.must_raise Nack::ParamError + end + + it "parses a nested query" do + parser.parse_nested_query("a=1&a=2").must_equal({"a" => "2"}) + parser.parse_nested_query("a[]=1&a[]=2").must_equal({"a" => ["1", "2"]}) + parser.parse_nested_query("a[b]=1").must_equal({"a" => {"b" => "1"}}) + parser.parse_nested_query("a[b][]=1").must_equal({"a" => {"b" => ["1"]}}) + end + + it "keeps a malformed nested name flat" do + parser.parse_nested_query("[]&]a[]&a[[]&a[]]").must_equal({"[]" => "", "]a[]" => "", "a[[]" => "", "a[]]" => ""}) + end + + it "raises ParamError beyond the depth limit" do + limited = Nack::QueryParser.new(depth_limit: 1) + limited.parse_nested_query("a").must_equal({"a" => ""}) + -> { limited.parse_nested_query("a[b]") }.must_raise Nack::ParamError + end + + it "reuses the last array element unless occupied" do + parser.parse_nested_query("a[][]=1&a[][]=2").must_equal({"a" => [["1", "2"]]}) + parser.parse_nested_query("a[][b][c]=1&a[][b][d]=2&a[][b][c]=3").must_equal({"a" => [{"b" => {"c" => "1", "d" => "2"}}, {"b" => {"c" => "3"}}]}) + parser.parse_nested_query("a[][b]=1&a[][b][c]=2").must_equal({"a" => [{"b" => "1"}, {"b" => {"c" => "2"}}]}) + parser.parse_nested_query("a[][]=1&a[][b]=2").must_equal({"a" => [["1"], {"b" => "2"}]}) + end + + it "raises ParamError on conflicting shapes" do + ["a[]&a", "a[b]&a", "a&a[]", "a&a[b]", "a[]&a[b]", "a[b]&a[]"].each do |query| + -> { parser.parse_nested_query(query) }.must_raise Nack::ParamError + end + end + + it "builds a query per WHATWG" do + parser.build_query([["a", "1"], ["b", "2"], ["a", "3"]]).must_equal "a=1&b=2&a=3" + parser.build_query([["a", ""]]).must_equal "a=" + parser.build_query([[" ", " "]]).must_equal "+=+" + end + + it "builds a nested query" do + params = {"a" => ["1", "2"], "b" => {"c" => "3"}, "d" => [{"e" => "4", "f" => "5"}, {"e" => "6"}]} + query = parser.build_nested_query(params) + query.must_equal "a%5B%5D=1&a%5B%5D=2&b%5Bc%5D=3&d%5B%5D%5Be%5D=4&d%5B%5D%5Bf%5D=5&d%5B%5D%5Be%5D=6" + parser.parse_nested_query(query).must_equal params + parser.build_nested_query({" " => {" " => " "}}).must_equal "+%5B+%5D=+" + parser.build_nested_query({"a" => [], "b" => {}}).must_equal "" + end +end