Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
50 changes: 50 additions & 0 deletions internal/vm/rack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
})
Expand Down
104 changes: 104 additions & 0 deletions internal/vm/rack_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package vm

import (
"strconv"
"strings"

rack "github.com/go-ruby-rack/rack"

Expand Down Expand Up @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions internal/vm/rack_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)}}
Expand Down
Loading