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
6 changes: 5 additions & 1 deletion app/params/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package params

import (
"github.com/sei-protocol/sei-chain/config/registry"
evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config"
srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config"
"github.com/sei-protocol/sei-chain/sei-cosmos/types/address"
Expand Down Expand Up @@ -94,8 +95,11 @@ const (
)

// IsFullnodeType returns true if the node is a fullnode-like node (full or archive)
//
// The rule itself lives in the configuration registry, because a section's own package needs the same
// fact and cannot import this one.
func (m NodeMode) IsFullnodeType() bool {
return m == NodeModeFull || m == NodeModeArchive
return registry.IsFullnodeMode(registry.Mode(m))
}

// setValidatorTypeTendermintConfig sets common Tendermint config for validator-like nodes
Expand Down
110 changes: 110 additions & 0 deletions config/registry/detach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package registry_test

import (
"reflect"
"sort"
"testing"

"github.com/sei-protocol/sei-chain/config/registry"
)

// listBearing is a probe whose default is a package-level variable, which is the usual shape.
type listBearing struct {
Allowed []string `mapstructure:"allowed"`
Labels map[string]string `mapstructure:"labels"`
Absent []string `mapstructure:"absent"`
}

var listBearingDefault = listBearing{
Allowed: []string{"callTracer", "prestateTracer"},
Labels: map[string]string{"chain": "pacific-1"},
}

// TestAResolvedListIsTheCallersToWriteInto covers what a caller may do with a resolved value.
//
// A section's default is usually a package-level variable, so handing out its slice hands out the array
// that variable holds. A caller sorting or de-duplicating a resolved list in place, which is what a caller
// producing deterministic output does, would rewrite that variable for the whole process: every later
// resolution and every reader that copies the same struct. Two of the lists this reaches in practice are
// deny lists, so the rewrite is silent and it is a security control.
func TestAResolvedListIsTheCallersToWriteInto(t *testing.T) {
registry.Reset()
registry.RegisterSection("probe", &listBearing{}, func(registry.Mode) any { return listBearingDefault })
for _, d := range registry.Defects() {
t.Fatalf("the probe was refused: %v", d.Err)
}

resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{})
if err != nil {
t.Fatalf("Resolve: %v", err)
}

resolved.Values["probe.allowed"].([]string)[0] = "written-by-the-caller"
resolved.Values["probe.labels"].(map[string]string)["chain"] = "written-by-the-caller"

if got := listBearingDefault.Allowed[0]; got != "callTracer" {
t.Errorf("writing into the resolved list changed the section's own default to %q, so every later "+
"resolution and every reader copying that struct carries the caller's value", got)
}
if got := listBearingDefault.Labels["chain"]; got != "pacific-1" {
t.Errorf("writing into the resolved map changed the section's own default to %q", got)
}

again, err := registry.Resolve(registry.ModeFull, registry.Sources{})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if got := again.Values["probe.allowed"]; !reflect.DeepEqual(got, []string{"callTracer", "prestateTracer"}) {
t.Errorf("a later resolution carries %v, so one caller's edit reached another's answer", got)
}

// A nil list stays nil rather than becoming an empty one, because absent and empty are different
// answers to a reader that checks length.
if got := again.Values["probe.absent"]; got == nil || !reflect.ValueOf(got).IsNil() {
t.Errorf("an unset list resolved to %#v, want a nil slice of its own type", got)
}
}

// TestSortingAResolvedNestedListLeavesTheSectionsOwnDefaultAlone is the same property one level down.
//
// A copy of the outer list or map still points at the inner storage, so a caller sorting an inner list
// reaches the section's own default exactly as directly as sorting the outer one would. The damage is the
// same and it is worse to find: the process-wide default is rewritten, and the resolution that carries it
// is the next one, in whatever code happens to run after.
//
// Driven through a second resolution rather than by inspecting the variable, because what matters is not
// that a copy was taken but that the next caller is unaffected.
func TestSortingAResolvedNestedListLeavesTheSectionsOwnDefaultAlone(t *testing.T) {
type nested struct {
Deny map[string][]string `mapstructure:"deny"`
}
// A package-level default is the shape every section uses, so the value handed out is the one the
// process keeps.
shared := nested{Deny: map[string][]string{"rpc": {"zebra", "aardvark"}}}
registry.RegisterSection("detach_nested", &nested{}, func(registry.Mode) any { return shared })

first, err := registry.Resolve(registry.ModeValidator, registry.Sources{})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
handed, ok := first.Values["detach_nested.deny"].(map[string][]string)
if !ok {
t.Fatalf("detach_nested.deny resolved to %T, want a map of lists", first.Values["detach_nested.deny"])
}
// What a caller does with a list it was given.
sort.Strings(handed["rpc"])

second, err := registry.Resolve(registry.ModeValidator, registry.Sources{})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
again, ok := second.Values["detach_nested.deny"].(map[string][]string)
if !ok {
t.Fatalf("the second resolution carried %T", second.Values["detach_nested.deny"])
}
if want := []string{"zebra", "aardvark"}; !reflect.DeepEqual(again["rpc"], want) {
t.Errorf("a later resolution carries %v, want %v. Sorting a list inside a resolved value rewrote "+
"the section's own default, so every resolution after it carries the sorted one",
again["rpc"], want)
}
}
11 changes: 11 additions & 0 deletions config/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ const (
// Modes returns every mode a default is asked for, in a fixed order.
func Modes() []Mode { return []Mode{ModeValidator, ModeFull, ModeSeed, ModeArchive} }

// IsFullnodeMode reports whether a node of this kind serves queries to callers other than itself.
//
// Stated here because more than one package needs it and they sit on opposite sides of an import edge.
// The package that owns the node a binary was started as also owns the type that describes it, and a
// section's own package needs the same fact to state a default that varies on it while being imported by
// that package rather than importing it.
//
// An archive node counts. It serves queries, which is the property this names, and it is the mode most
// easily forgotten when the rule is written out by hand.
func IsFullnodeMode(mode Mode) bool { return mode == ModeFull || mode == ModeArchive }

// Section is one registered configuration section.
type Section struct {
// Name is the section's own segment, and the first segment of every key it declares.
Expand Down
57 changes: 56 additions & 1 deletion config/registry/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import (
// Resolved is every declared key's value, plus what a caller has to be told about how it got there.
type Resolved struct {
// Values carries one value per declared key.
//
// A key's Go type depends on which source answered it, and a caller that type-asserts has to expect
// all three. A default arrives as the field's own type, so a duration is a duration and a list is a
// list. A file arrives as whatever the file format decodes to, so the same duration is text and the
// same list is a list of untyped elements. An environment variable arrives as one string, always. This
// resolves values and does not convert them, so the reader that owns a key remains the thing that
// turns any of the three into what that key means.
//
// A value is the caller's to write into. Nothing here shares storage with a section's own default.
Comment thread
bdchatham marked this conversation as resolved.
Values map[string]any
// Overrides are the declared keys something other than this node's defaults supplied, sorted.
//
Expand Down Expand Up @@ -259,11 +268,57 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error {
}
continue
}
out[path] = fv.Interface()
out[path] = detach(fv)
}
return nil
}

// detach returns a value that shares no storage with the one it was given.
//
// A section's default is usually a package-level variable, so handing out a list or a map field hands out
// the storage behind it. One caller sorting what it was given rewrites the process-wide default and every
// later resolution carries the sorted version. That happened here, to two deny lists.
//
// Copied all the way down rather than one level. A list of lists, or a map of lists, shares its inner
// storage through a copy of the outer, so a caller sorting an inner list reaches the default just as
// directly. Nothing declared today has that shape, and the copy runs once per boot over a hundred or so
// keys, so the cost of being thorough here is not measurable and the cost of not being is a defect nobody
// finds twice.
//
// This covers what a section's defaults answer. It is not the guarantee for a value a source supplied: the
// file source hands out its own copies, recursively, where it reads them, and the flag source carries only
// text. A source that grew a channel handing out live storage would have to do the same at its own edge,
// because only the source knows what else holds it.
func detach(v reflect.Value) any {
switch v.Kind() {
case reflect.Slice:
if v.IsNil() {
return v.Interface()
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(reflect.ValueOf(detach(v.Index(i))))
}
return out.Interface()
case reflect.Map:
if v.IsNil() {
return v.Interface()
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
for _, key := range v.MapKeys() {
out.SetMapIndex(key, reflect.ValueOf(detach(v.MapIndex(key))))
}
return out.Interface()
case reflect.Interface:
if v.IsNil() {
return v.Interface()
}
return detach(v.Elem())
default:
return v.Interface()
}
}

// envValues reads the keys an environment supplies, from the caller's declared set.
//
// Driven by the declared set rather than by the environment, which is also what makes it complete:
Expand Down
54 changes: 54 additions & 0 deletions evmrpc/config/register.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package config

import "github.com/sei-protocol/sei-chain/config/registry"

// SectionName is this section's name in the configuration key space.
const SectionName = "evm"

// Registration puts this package's configuration section in the registry.
//
// The owning package registers its own section, so the struct, the values and the keys come from one
// place. This section's mapstructure tags already spell the keys its reader resolves, so the registry
// derives what a node reads rather than restating them.
func init() {
registry.RegisterSection(SectionName, &Config{}, defaults)
}

// defaults is what the seid init command writes for a node of this kind.
//
// That command applies the same mode rule to this section's own defaults and renders the result, and what
// it renders is passed through rather than refilled from a mode-blind copy, so a declared value here is the
// value that reaches the file.
//
// The two interface toggles are what the rule changes. A full node and an archive node serve queries, which
// is what these interfaces are for; a validator and a seed serve none, and leaving them open would put a
// public request surface on the node that holds a signing key. The rule is read from the registry rather
// than restated, because the package that owns the node mode imports this one and cannot be imported back.
//
// Two values come from the machine rather than from a decision, and they are not one case. The worker pool
// has a portable answer: the pool re-measures whenever the value it is given is not positive, so a file
// carrying zero lets every node size itself, and a caller rendering into a file should write that rather
// than this. The simulation call limit has no portable answer, because zero there is not a request to
// measure but the absence of a limit, and the limit is the only bound on how many simulations a node runs
// at once. Both describe the host that resolved them, so neither travels.
//
// The two interface toggles differ from what this package's own reader falls back to, and the difference is
// worth naming because it looks like a behaviour change and is not one. The reader's default has both open
// for every kind of node, and it keeps that when the key is absent from a file. The values here follow the
// rule the provisioning command applies instead, so they close both for a node that serves no queries.
//
// Nothing flips as a result. A resolution records which keys a source supplied, and only those are ever
// delivered, so a node whose file omits these keys has nothing written for them and the reader's own
// fallback still answers. A value from here reaches a node when somebody wrote it and at no other time.
//
// What would change that is a writer that renders every declared key into a file. Then these two are
// supplied, the node closes them, and an operator can at least read why in the file they were handed. That
// is the case to remember when such a writer is built, which is why it is written here rather than left to
// be discovered there.
func defaults(mode registry.Mode) any {
cfg := DefaultConfig
serves := registry.IsFullnodeMode(mode)
cfg.HTTPEnabled = serves
Comment thread
bdchatham marked this conversation as resolved.
cfg.WSEnabled = serves
return cfg
}
Loading
Loading