diff --git a/go.mod b/go.mod index 296b3aac..72e30422 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,7 @@ require ( github.com/go-ruby-prime/prime v0.0.0-20260703153932-08f6fe218cd4 github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444 - github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5 + github.com/go-ruby-rack/rack v0.0.0-20260703100219-0201e5681658 github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92 github.com/go-ruby-regexp/regexp v0.0.0-20260703193131-c52ca89ccd08 github.com/go-ruby-resolv/resolv v0.0.0-20260629153520-df410a5796ac diff --git a/go.sum b/go.sum index e37ed45a..84338d2f 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e h1:JmbJL+0nq github.com/go-ruby-pstore/pstore v0.0.0-20260630081017-0dd55a12f94e/go.mod h1:tbYmHYXjnNXJCNYABDx9nWfuGBCXeKrp134bNsQeTpM= github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444 h1:vkMPJDpZHmu09pVxr7CFppoh1b5Kc8LkhKpxkSBoyxA= github.com/go-ruby-public-suffix/public-suffix v0.0.0-20260630151503-f308d4002444/go.mod h1:Wq3cgP6klsULbO4ee0DOavVIzifgmddOWnYpqljbER4= -github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5 h1:8jrY0g3k1+TbiXz7jV8sXP8UcXSlB1FKcXWkyXZPBQ8= -github.com/go-ruby-rack/rack v0.0.0-20260630124607-8a4f9cfcdbb5/go.mod h1:rKZ3vD81gJq61NvZ3XX9cr6AxtIV3+lFZKG7lPKV3PM= +github.com/go-ruby-rack/rack v0.0.0-20260703100219-0201e5681658 h1:IBLd1bBlCdCNH/xOSp6+AchixWQE1aMjtFYw4O2hhAg= +github.com/go-ruby-rack/rack v0.0.0-20260703100219-0201e5681658/go.mod h1:rKZ3vD81gJq61NvZ3XX9cr6AxtIV3+lFZKG7lPKV3PM= github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92 h1:EXqNsTpyz5KPZURl94uOjcIIyJ0mCyMA7/AFAXvFgdg= github.com/go-ruby-redis/redis v0.0.0-20260701125752-5de216f6ad92/go.mod h1:gt67ft80L9J5DpRGe+x3qPy2hg1g75aWQ6h52inm7og= github.com/go-ruby-regexp/regexp v0.0.0-20260703193131-c52ca89ccd08 h1:2ULLhB9SUBzHngWYrbltWEmt7jCbA1SvweZsmKwdilM= diff --git a/internal/vm/rack.go b/internal/vm/rack.go index 257a0464..b4628f05 100644 --- a/internal/vm/rack.go +++ b/internal/vm/rack.go @@ -337,6 +337,56 @@ func (vm *VM) registerRackUtils(mod *RClass) { } return object.NewString(m) }) + // secure_compare(a, b) — constant-time string equality (CSRF/token-safe). + def("secure_compare", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + return object.Bool(rack.SecureCompare(rackStr(args[0]), rackStr(args[1]))) + }) + // clean_path_info(path) — traversal-safe PATH_INFO normalisation. + def("clean_path_info", strFn(rack.CleanPathInfo)) + // valid_path?(path) — valid UTF-8 and NUL-free. + def("valid_path?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return object.Bool(rack.ValidPath(rackStr(rackArg(args)))) + }) + // select_best_encoding(available_encodings, accept_encoding) — pick the best + // content-coding. Note the MRI arg order: the server's available list first, + // the pre-parsed [name, quality] accept list second. Returns nil when nothing + // is acceptable. + def("select_best_encoding", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + best, ok := rack.SelectBestEncoding(rackStrArray(args[0]), rackQValues(args[1])) + if !ok { + return object.NilV + } + return object.NewString(best) + }) + // forwarded_values(forwarded_header) — parse an RFC 7239 Forwarded header into + // a Hash of symbol parameter name → ordered value list, in header order. A + // falsy header or a disallowed parameter yields nil (MRI's nil return). + def("forwarded_values", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) == 0 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1)") + } + header, has := rackForwardedArg(args[0]) + vals, present := rack.ForwardedValues(header, has) + if !present { + return object.NilV + } + h := object.NewHash() + for _, k := range rackForwardedOrder(header) { + list := vals[k] + arr := object.NewArrayFromSlice(make([]object.Value, len(list))) + for i, s := range list { + arr.Elems[i] = object.NewString(s) + } + h.Set(object.Symbol(k), arr) + } + return h + }) def("parse_cookies_header", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { return rackParamsToHash(rack.ParseCookiesHeader(rackStr(rackArg(args)))) }) diff --git a/internal/vm/rack_helpers.go b/internal/vm/rack_helpers.go index ad29cdd9..d1f71fb4 100644 --- a/internal/vm/rack_helpers.go +++ b/internal/vm/rack_helpers.go @@ -6,6 +6,7 @@ package vm import ( "strconv" + "strings" rack "github.com/go-ruby-rack/rack" @@ -267,6 +268,109 @@ func rackCookieHeader(v object.Value) string { return "" } +// rackFloat reads an argument as a float64, used for the quality weights of the +// [name, quality] pairs Rack::Utils.select_best_encoding accepts. An Integer +// coerces to its float value; anything non-numeric yields 0. +func rackFloat(v object.Value) float64 { + switch n := v.(type) { + case object.Float: + return float64(n) + case object.Integer: + return float64(n) + } + return 0 +} + +// rackQValues maps a Ruby Array of [name, quality] pairs (the pre-parsed +// accept_encoding argument of Rack::Utils.select_best_encoding) into a +// []rack.QValue. A non-Array argument yields nil; elements that are not at least +// two-long arrays are skipped, mirroring MRI's `each { |m, q| }` destructuring. +func rackQValues(v object.Value) []rack.QValue { + arr, ok := v.(*object.Array) + if !ok { + return nil + } + out := make([]rack.QValue, 0, len(arr.Elems)) + for _, el := range arr.Elems { + pair, ok := el.(*object.Array) + if !ok || len(pair.Elems) < 2 { + continue + } + out = append(out, rack.QValue{Value: rackStr(pair.Elems[0]), Quality: rackFloat(pair.Elems[1])}) + } + return out +} + +// rackAllowedForwarded is the set of Forwarded-header parameters Rack::Utils +// accepts, mirroring rack.ForwardedValues' allow-list. +var rackAllowedForwarded = map[string]bool{"by": true, "for": true, "host": true, "proto": true} + +// rackForwardedArg reads the single argument of Rack::Utils.forwarded_values and +// splits it into the (header, present) pair rack.ForwardedValues consumes. A +// falsy argument (nil / false) is absent (present=false), matching MRI's +// `return nil unless forwarded_header`; any other value is stringified (MRI +// applies #to_s before parsing). +func rackForwardedArg(v object.Value) (string, bool) { + switch n := v.(type) { + case nil, object.Nil: + return "", false + case object.Bool: + if !bool(n) { + return "", false + } + } + return rackStr(v), true +} + +// rackForwardedOrder returns the allowed Forwarded parameter names in +// first-appearance order, mirroring rack.ForwardedValues' tokeniser (names only) +// so the Ruby Hash reproduces MRI's header-order enumeration — the Go func +// returns an unordered map[string][]string, which alone cannot preserve order. +// It is only ever called after ForwardedValues reports present, so every name it +// yields is a key of that map. +func rackForwardedOrder(header string) []string { + const seps = " \t;," + h := strings.TrimLeft(strings.ReplaceAll(header, "\n", ";"), seps) + var order []string + seen := map[string]bool{} + for { + eq := strings.IndexByte(h, '=') + if eq < 0 { + break + } + name := strings.ToLower(strings.TrimSpace(h[:eq])) + h = h[eq+1:] + if len(h) > 0 && h[0] == '"' { + h = h[1:] + for { + i := strings.IndexAny(h, "\"\\") + if i < 0 { + h = "" + break + } + c := h[i] + h = h[i+1:] + if c == '"' { + break + } + if len(h) > 0 { + h = h[1:] + } + } + } else if i := strings.IndexAny(h, ";,"); i >= 0 { + h = h[i:] + } else { + h = "" + } + if rackAllowedForwarded[name] && !seen[name] { + seen[name] = true + order = append(order, name) + } + h = strings.TrimLeft(h, seps) + } + return order +} + // rackToGo maps a Ruby value into the generic Go value model rack consumes // (nil / bool / int64 / float64 / string / []any / map[string]any). func rackToGo(v object.Value) any { diff --git a/internal/vm/rack_internal_test.go b/internal/vm/rack_internal_test.go index f04911ea..9215e7e2 100644 --- a/internal/vm/rack_internal_test.go +++ b/internal/vm/rack_internal_test.go @@ -191,6 +191,30 @@ func TestRackUtils(t *testing.T) { {`require "rack/utils"; puts Rack::Utils.status_code("500")`, "500\n"}, {`require "rack/utils"; puts Rack::Utils::HTTP_STATUS_CODES[404]`, "Not Found\n"}, {`require "rack/utils"; puts Rack::Utils::HTTP_STATUS_CODES[200]`, "OK\n"}, + // secure_compare / clean_path_info / valid_path? — MRI-parity arms. + {`require "rack/utils"; puts Rack::Utils.secure_compare("s3cr3t", "s3cr3t")`, "true\n"}, + {`require "rack/utils"; puts Rack::Utils.secure_compare("s3cr3t", "s3cr3x")`, "false\n"}, + {`require "rack/utils"; puts Rack::Utils.secure_compare("abc", "abcd")`, "false\n"}, + {`require "rack/utils"; puts Rack::Utils.clean_path_info("/foo/../bar")`, "/bar\n"}, + {`require "rack/utils"; puts Rack::Utils.clean_path_info("a/./b/../c")`, "a/c\n"}, + {`require "rack/utils"; puts Rack::Utils.clean_path_info("/../../etc/passwd")`, "/etc/passwd\n"}, + {`require "rack/utils"; puts Rack::Utils.clean_path_info("").inspect`, `"/"` + "\n"}, + {`require "rack/utils"; puts Rack::Utils.valid_path?("/ok/path")`, "true\n"}, + {`require "rack/utils"; puts Rack::Utils.valid_path?("bad\x00path")`, "false\n"}, + // select_best_encoding — note the (available, [[name, q], …]) arg order. + {`require "rack/utils"; puts Rack::Utils.select_best_encoding(["gzip","identity"], [["gzip",1.0],["identity",0.5]])`, "gzip\n"}, + {`require "rack/utils"; puts Rack::Utils.select_best_encoding(["gzip","deflate","identity"], [["*",1.0]])`, "gzip\n"}, + {`require "rack/utils"; puts Rack::Utils.select_best_encoding(["gzip","identity"], []).inspect`, `"identity"` + "\n"}, + {`require "rack/utils"; p Rack::Utils.select_best_encoding(["gzip"], [["identity",0.0],["gzip",0.0]])`, "nil\n"}, + // forwarded_values — symbol keys, header order; nil on falsy/disallowed. + {`require "rack/utils"; puts Rack::Utils.forwarded_values("for=1.2.3.4;proto=https").inspect`, `{for: ["1.2.3.4"], proto: ["https"]}` + "\n"}, + {`require "rack/utils"; puts Rack::Utils.forwarded_values("proto=https;for=1.2.3.4").inspect`, `{proto: ["https"], for: ["1.2.3.4"]}` + "\n"}, + {`require "rack/utils"; puts Rack::Utils.forwarded_values("host=example.com;by=a;for=b;proto=c").inspect`, `{host: ["example.com"], by: ["a"], for: ["b"], proto: ["c"]}` + "\n"}, + {`require "rack/utils"; puts Rack::Utils.forwarded_values("for=1.1.1.1, for=2.2.2.2").inspect`, `{for: ["1.1.1.1", "2.2.2.2"]}` + "\n"}, + {`require "rack/utils"; puts Rack::Utils.forwarded_values("").inspect`, "{}\n"}, + {`require "rack/utils"; p Rack::Utils.forwarded_values(nil)`, "nil\n"}, + {`require "rack/utils"; p Rack::Utils.forwarded_values(false)`, "nil\n"}, + {`require "rack/utils"; p Rack::Utils.forwarded_values("cookie=x")`, "nil\n"}, } for _, c := range cases { if got := eval(t, c.src); got != c.want { @@ -204,6 +228,9 @@ func TestRackUtils(t *testing.T) { `require "rack/utils"; Rack::Utils.build_nested_query("scalar")`, `require "rack/utils"; Rack::Utils.status_code(:bogus)`, `require "rack/utils"; Rack::Utils.best_q_match("x")`, + `require "rack/utils"; Rack::Utils.secure_compare("only-one")`, + `require "rack/utils"; Rack::Utils.select_best_encoding(["gzip"])`, + `require "rack/utils"; Rack::Utils.forwarded_values`, } { if class, _ := evalErr(t, src); class != "ArgumentError" { t.Errorf("src=%q class=%q want ArgumentError", src, class) @@ -397,6 +424,81 @@ func TestRackFromGo(t *testing.T) { } } +// TestRackFloat covers the Float / Integer / default arms of the quality +// coercion used by select_best_encoding. +func TestRackFloat(t *testing.T) { + if rackFloat(object.Float(0.5)) != 0.5 { + t.Error("float arm") + } + if rackFloat(object.Integer(1)) != 1.0 { + t.Error("integer arm") + } + if rackFloat(object.NewString("x")) != 0 { + t.Error("default arm") + } +} + +// TestRackQValues covers the non-Array, skip (non-pair / short) and valid arms +// of the accept_encoding converter. +func TestRackQValues(t *testing.T) { + if rackQValues(object.NilV) != nil { + t.Error("non-Array arm") + } + in := object.NewArray( + object.NewString("scalar"), // skipped: not a pair + object.NewArray(object.NewString("short")), // skipped: < 2 elems + object.NewArray(object.NewString("gzip"), object.Float(0.7)), // kept + ) + got := rackQValues(in) + if len(got) != 1 || got[0].Value != "gzip" || got[0].Quality != 0.7 { + t.Errorf("valid arm got=%#v", got) + } +} + +// TestRackForwardedArg covers the nil, false and truthy arms. +func TestRackForwardedArg(t *testing.T) { + if _, ok := rackForwardedArg(object.NilV); ok { + t.Error("nil arm") + } + if _, ok := rackForwardedArg(nil); ok { + t.Error("go-nil arm") + } + if _, ok := rackForwardedArg(object.Bool(false)); ok { + t.Error("false arm") + } + s, ok := rackForwardedArg(object.NewString("for=x")) + if !ok || s != "for=x" { + t.Errorf("truthy arm got=%q ok=%v", s, ok) + } + // A truthy non-string is stringified (MRI applies #to_s). + if s, ok := rackForwardedArg(object.Bool(true)); !ok || s != "true" { + t.Errorf("truthy-bool arm got=%q ok=%v", s, ok) + } +} + +// TestRackForwardedOrder covers every arm of the header-order reconstruction: +// terminating quote, escaped quote, unterminated quote, separator, end-of-input, +// duplicate keys (seen), and a disallowed name (dropped). +func TestRackForwardedOrder(t *testing.T) { + cases := []struct { + header string + want []string + }{ + {"", nil}, + {"for=b;host=h", []string{"for", "host"}}, + {`for="a\"b";proto=x`, []string{"for", "proto"}}, + {`for="unterminated`, []string{"for"}}, + {"for=a;for=b;host=h", []string{"for", "host"}}, + {"cookie=x;for=y", []string{"for"}}, + {"\nfor=z", []string{"for"}}, + } + for _, c := range cases { + if got := rackForwardedOrder(c.header); !reflect.DeepEqual(got, c.want) { + t.Errorf("rackForwardedOrder(%q)=%#v want %#v", c.header, got, c.want) + } + } +} + // TestRackToGo covers every arm of the object->Go converter. func TestRackToGo(t *testing.T) { arr := &object.Array{Elems: []object.Value{object.Integer(1)}}