From b408f7f0c7e64270a3d16acd9efa25ccec4b2efa Mon Sep 17 00:00:00 2001 From: Meo597 <197331664+Meo597@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:36:12 +0800 Subject: [PATCH] lua: add recursive read-only bindings for borrowed slices --- app/dns/lua.go | 2 +- app/router/lua.go | 2 +- common/geodata/lua.go | 2 +- common/lua/luar.go | 49 +++++++++++++++ common/lua/luar_test.go | 135 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 3 deletions(-) diff --git a/app/dns/lua.go b/app/dns/lua.go index 3c5194baf..1377f5740 100644 --- a/app/dns/lua.go +++ b/app/dns/lua.go @@ -52,7 +52,7 @@ func luaServers(s *DNS) []luaDNSServer { func registerLua(L *lua.LState, servers []luaDNSServer, client featureDNS.Client) { L.PreloadModule("xray.dns", func(L *lua.LState) int { - pushIPs := xlua.NewSlicePusher[net.IP](L) + pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L) serverList := L.CreateTable(len(servers), 0) for i, client := range servers { diff --git a/app/router/lua.go b/app/router/lua.go index 75da27ccc..fd808f710 100644 --- a/app/router/lua.go +++ b/app/router/lua.go @@ -62,7 +62,7 @@ func (r *Router) RegisterLua(L *lua.LState) { } func registerLuaContext(L *lua.LState) { - pushIPs := xlua.NewSlicePusher[net.IP](L) + pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L) attributes := L.NewTypeMetatable(luaAttributesType) L.SetField(attributes, "__index", L.NewFunction(func(L *lua.LState) int { values := L.CheckUserData(1).Value.(map[string]string) diff --git a/common/geodata/lua.go b/common/geodata/lua.go index d74887d90..e144aa491 100644 --- a/common/geodata/lua.go +++ b/common/geodata/lua.go @@ -44,7 +44,7 @@ func RegisterLua(L *lua.LState) { "Match": luaIPMatch, "AnyMatch": luaIPAnyMatch, "Matches": luaIPMatches, - "FilterIPs": newLuaIPFilterIPs(xlua.NewSlicePusher[net.IP](L)), + "FilterIPs": newLuaIPFilterIPs(xlua.NewReadOnlySlicePusher[net.IP](L)), }, "SetReverse", "ToggleReverse") return 1 })) diff --git a/common/lua/luar.go b/common/lua/luar.go index 8780db454..1bfbce7c3 100644 --- a/common/lua/luar.go +++ b/common/lua/luar.go @@ -1,6 +1,9 @@ package lua import ( + "net" + "reflect" + glua "github.com/yuin/gopher-lua" luar "layeh.com/gopher-luar" ) @@ -22,6 +25,52 @@ func NewSlicePusher[T any](L *glua.LState) func(*glua.LState, []T) { } } +// NewReadOnlySlicePusher borrows slices without copying and recursively makes +// nested slice types read-only through their shared metatables. IP byte views +// and masks are also read-only. Other referenced elements retain their mutability. +func NewReadOnlySlicePusher[T any](L *glua.LState) func(*glua.LState, []T) { + sliceMetatable := luar.MT(L, []T{}) + if sliceMetatable.RawGetString("__readonly") != glua.LTrue { + readOnly := L.NewFunction(func(L *glua.LState) int { + L.RaiseError("slice is read-only") + return 0 + }) + setSliceReadOnly[[]T](L, readOnly) + } + return NewSlicePusher[T](L) +} + +func setSliceReadOnly[S ~[]E, E any](L *glua.LState, readOnly *glua.LFunction) { + var visit func(reflect.Type) + visit = func(typ reflect.Type) { + if typ.Kind() != reflect.Slice { + return + } + metatable := luar.MT(L, reflect.Zero(typ).Interface()) + if metatable.RawGetString("__readonly") == glua.LTrue { + return + } + // Mark before descending because named slice types can be recursive. + metatable.RawSetString("__readonly", glua.LTrue) + metatable.RawSetString("__newindex", readOnly) + // Reject explicitly so Lua cannot fall back to the other operand. + metatable.RawSetString("__add", readOnly) + if typ == reflect.TypeFor[net.IP]() { + // A nil receiver lets AppendText return its argument as a writable []byte. + methods := metatable.RawGetString("methods").(*glua.LTable) + appendText := methods.RawGetString("AppendText") + methods.ForEach(func(key, method glua.LValue) { + if method == appendText { + methods.RawSet(key, glua.LNil) + } + }) + visit(reflect.TypeFor[net.IPMask]()) + } + visit(typ.Elem()) + } + visit(reflect.TypeFor[S]()) +} + // DirectMethod handles a Lua call without luar's reflected method invocation. // It returns the result count and whether it handled the arguments. On false, // it must leave the stack unchanged for the original luar wrapper. diff --git a/common/lua/luar_test.go b/common/lua/luar_test.go index cad82b6c6..64ba13624 100644 --- a/common/lua/luar_test.go +++ b/common/lua/luar_test.go @@ -47,6 +47,141 @@ assert(ok and result == true) } } +func TestReadOnlySlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := []int{3, 5} + NewReadOnlySlicePusher[int](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + if err := L.DoString(` +assert(#values == 2 and values[1] == 3 and values[2] == 5) +assert(not pcall(function() values[1] = 7 end)) +assert(not pcall(function() return values + 7 end)) +local sum = 0 +for _, value in values() do sum = sum + value end +assert(sum == 8) +`); err != nil { + t.Fatal(err) + } + got := L.GetGlobal("values").(*glua.LUserData).Value.([]int) + if &got[0] != &values[0] || values[0] != 3 || values[1] != 5 { + t.Fatal("read-only binding copied or modified the slice") + } +} + +func TestReadOnlyIPSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + ips := []net.IP{{192, 0, 2, 1}, net.ParseIP("2001:db8::1")} + NewReadOnlySlicePusher[net.IP](L)(L, ips) + L.SetGlobal("ips", L.Get(-1)) + L.Pop(1) + readOnly := luar.MT(L, ips).RawGetString("__newindex") + NewReadOnlySlicePusher[net.IP](L) + if luar.MT(L, ips).RawGetString("__newindex") != readOnly { + t.Fatal("IP metatables were initialized more than once") + } + if err := L.DoString(` +local function readOnly(value) + assert(not pcall(function() value[1] = value[1] end)) + assert(not pcall(function() return value + value[1] end)) +end +assert(#ips == 2 and ips[1]:String() == "192.0.2.1") +readOnly(ips) +for _, ip in ips() do + readOnly(ip) + readOnly(ip:To16()) + if ip:To4() then + readOnly(ip:To4()) + readOnly(ip:DefaultMask()) + end + assert(ip:Equal(ip:To16()) and type(ip:String()) == "string") + assert(ip.AppendText == nil and ip.appendText == nil) + local text, err = ip:MarshalText() + assert(not err) + text[1] = 88 -- Independently owned byte buffers remain writable. + assert(not pcall(function() return ips + text end)) +end +`); err != nil { + t.Fatal(err) + } + got := L.GetGlobal("ips").(*glua.LUserData).Value.([]net.IP) + if &got[0] != &ips[0] || &got[0][0] != &ips[0][0] || &got[1][0] != &ips[1][0] { + t.Fatal("read-only binding copied the IP data") + } + for i, want := range []string{"192.0.2.1", "2001:db8::1"} { + if got[i].String() != want { + t.Fatalf("IP %d changed to %s; want %s", i, got[i], want) + } + } +} + +func TestReadOnlyNestedSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := [][][]byte{{{3, 5}}} + NewReadOnlySlicePusher[[][]byte](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + ips := [][]net.IP{{{192, 0, 2, 1}}} + NewReadOnlySlicePusher[[]net.IP](L)(L, ips) + L.SetGlobal("ips", L.Get(-1)) + L.Pop(1) + readOnly := luar.MT(L, ips[0]).RawGetString("__newindex") + if err := L.DoString(` +local function readOnly(value) + assert(not pcall(function() value[1] = value[1] end)) + assert(not pcall(function() return value + value[1] end)) +end +assert(values[1][1][1] == 3 and values[1][1][2] == 5) +readOnly(values) +for _, group in values() do + readOnly(group) + for _, bytes in group() do readOnly(bytes) end +end +readOnly(ips) +readOnly(ips[1]) +local ip = ips[1][1] +assert(ip:String() == "192.0.2.1") +readOnly(ip) +readOnly(ip:To4()) +readOnly(ip:To16()) +readOnly(ip:DefaultMask()) +assert(ip.AppendText == nil and ip.appendText == nil) +`); err != nil { + t.Fatal(err) + } + NewReadOnlySlicePusher[net.IP](L) + if luar.MT(L, ips[0]).RawGetString("__newindex") != readOnly { + t.Fatal("nested slice metatable was initialized again") + } + got := L.GetGlobal("values").(*glua.LUserData).Value.([][][]byte) + if &got[0][0][0] != &values[0][0][0] || values[0][0][0] != 3 || values[0][0][1] != 5 { + t.Fatal("read-only binding copied or modified the nested slice") + } +} + +type recursiveReadOnlySlice []recursiveReadOnlySlice + +func TestReadOnlyRecursiveSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := make(recursiveReadOnlySlice, 1) + values[0] = values + NewReadOnlySlicePusher[recursiveReadOnlySlice](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + if err := L.DoString(` +local nested = values[1][1][1] +assert(#nested == 1) +assert(not pcall(function() nested[1] = nested end)) +assert(not pcall(function() return nested + nested end)) +`); err != nil { + t.Fatal(err) + } +} + func TestSlicePusherMetatablePerState(t *testing.T) { first := glua.NewState() defer first.Close()