Skip to content

Commit

Permalink
fix: Issue #9364 JSON config validation (#10679)
Browse files Browse the repository at this point in the history
* Fix JSON config validation
* updated ReflectToMap

---------

Co-authored-by: galargh <[email protected]>
Co-authored-by: Guillaume Michel <[email protected]>
Co-authored-by: guillaumemichel <[email protected]>
  • Loading branch information
4 people authored Feb 4, 2025
1 parent 4bd79bd commit 032ceaf
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 57 deletions.
101 changes: 101 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/ipfs/kubo/misc/fsutil"
Expand Down Expand Up @@ -137,6 +138,71 @@ func ToMap(conf *Config) (map[string]interface{}, error) {
return m, nil
}

// Convert config to a map, without using encoding/json, since
// zero/empty/'omitempty' fields are excluded by encoding/json during
// marshaling.
func ReflectToMap(conf interface{}) interface{} {
v := reflect.ValueOf(conf)
if !v.IsValid() {
return nil
}

// Handle pointer type
if v.Kind() == reflect.Ptr {
if v.IsNil() {
// Create a zero value of the pointer's element type
elemType := v.Type().Elem()
zero := reflect.Zero(elemType)
return ReflectToMap(zero.Interface())
}
v = v.Elem()
}

switch v.Kind() {
case reflect.Struct:
result := make(map[string]interface{})
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
// Only include exported fields
if field.CanInterface() {
result[t.Field(i).Name] = ReflectToMap(field.Interface())
}
}
return result

case reflect.Map:
result := make(map[string]interface{})
iter := v.MapRange()
for iter.Next() {
key := iter.Key()
// Convert map keys to strings for consistency
keyStr := fmt.Sprint(ReflectToMap(key.Interface()))
result[keyStr] = ReflectToMap(iter.Value().Interface())
}
// Add a sample to differentiate between a map and a struct on validation.
sample := reflect.Zero(v.Type().Elem())
if sample.CanInterface() {
result["*"] = ReflectToMap(sample.Interface())
}
return result

case reflect.Slice, reflect.Array:
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = ReflectToMap(v.Index(i).Interface())
}
return result

default:
// For basic types (int, string, etc.), just return the value
if v.CanInterface() {
return v.Interface()
}
return nil
}
}

// Clone copies the config. Use when updating.
func (c *Config) Clone() (*Config, error) {
var newConfig Config
Expand All @@ -152,3 +218,38 @@ func (c *Config) Clone() (*Config, error) {

return &newConfig, nil
}

// Check if the provided key is present in the structure.
func CheckKey(key string) error {
conf := Config{}

// Convert an empty config to a map without JSON.
cursor := ReflectToMap(&conf)

// Parse the key and verify it's presence in the map.
var ok bool
var mapCursor map[string]interface{}

parts := strings.Split(key, ".")
for i, part := range parts {
mapCursor, ok = cursor.(map[string]interface{})
if !ok {
if cursor == nil {
return nil
}
path := strings.Join(parts[:i], ".")
return fmt.Errorf("%s key is not a map", path)
}

cursor, ok = mapCursor[part]
if !ok {
// If the config sections is a map, validate against the default entry.
if cursor, ok = mapCursor["*"]; ok {
continue
}
path := strings.Join(parts[:i+1], ".")
return fmt.Errorf("%s not found", path)
}
}
return nil
}
132 changes: 132 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,135 @@ func TestClone(t *testing.T) {
t.Fatal("HTTP headers not preserved")
}
}

func TestReflectToMap(t *testing.T) {
// Helper function to create a test config with various field types
reflectedConfig := ReflectToMap(new(Config))

mapConfig, ok := reflectedConfig.(map[string]interface{})
if !ok {
t.Fatal("Config didn't convert to map")
}

reflectedIdentity, ok := mapConfig["Identity"]
if !ok {
t.Fatal("Identity field not found")
}

mapIdentity, ok := reflectedIdentity.(map[string]interface{})
if !ok {
t.Fatal("Identity field didn't convert to map")
}

// Test string field reflection
reflectedPeerID, ok := mapIdentity["PeerID"]
if !ok {
t.Fatal("PeerID field not found in Identity")
}
if _, ok := reflectedPeerID.(string); !ok {
t.Fatal("PeerID field didn't convert to string")
}

// Test omitempty json string field
reflectedPrivKey, ok := mapIdentity["PrivKey"]
if !ok {
t.Fatal("PrivKey omitempty field not found in Identity")
}
if _, ok := reflectedPrivKey.(string); !ok {
t.Fatal("PrivKey omitempty field didn't convert to string")
}

// Test slices field
reflectedBootstrap, ok := mapConfig["Bootstrap"]
if !ok {
t.Fatal("Bootstrap field not found in config")
}
bootstrap, ok := reflectedBootstrap.([]interface{})
if !ok {
t.Fatal("Bootstrap field didn't convert to []string")
}
if len(bootstrap) != 0 {
t.Fatal("Bootstrap len is incorrect")
}

reflectedDatastore, ok := mapConfig["Datastore"]
if !ok {
t.Fatal("Datastore field not found in config")
}
datastore, ok := reflectedDatastore.(map[string]interface{})
if !ok {
t.Fatal("Datastore field didn't convert to map")
}
storageGCWatermark, ok := datastore["StorageGCWatermark"]
if !ok {
t.Fatal("StorageGCWatermark field not found in Datastore")
}
// Test int field
if _, ok := storageGCWatermark.(int64); !ok {
t.Fatal("StorageGCWatermark field didn't convert to int64")
}
noSync, ok := datastore["NoSync"]
if !ok {
t.Fatal("NoSync field not found in Datastore")
}
// Test bool field
if _, ok := noSync.(bool); !ok {
t.Fatal("NoSync field didn't convert to bool")
}

reflectedDNS, ok := mapConfig["DNS"]
if !ok {
t.Fatal("DNS field not found in config")
}
DNS, ok := reflectedDNS.(map[string]interface{})
if !ok {
t.Fatal("DNS field didn't convert to map")
}
reflectedResolvers, ok := DNS["Resolvers"]
if !ok {
t.Fatal("Resolvers field not found in DNS")
}
// Test map field
if _, ok := reflectedResolvers.(map[string]interface{}); !ok {
t.Fatal("Resolvers field didn't convert to map")
}

// Test pointer field
if _, ok := DNS["MaxCacheTTL"].(map[string]interface{}); !ok {
// Since OptionalDuration only field is private, we cannot test it
t.Fatal("MaxCacheTTL field didn't convert to map")
}
}

// Test validation of options set through "ipfs config"
func TestCheckKey(t *testing.T) {
err := CheckKey("Foo.Bar")
if err == nil {
t.Fatal("Foo.Bar isn't a valid key in the config")
}

err = CheckKey("Provider.Strategy")
if err != nil {
t.Fatalf("%s: %s", err, "Provider.Strategy is a valid key in the config")
}

err = CheckKey("Provider.Foo")
if err == nil {
t.Fatal("Provider.Foo isn't a valid key in the config")
}

err = CheckKey("Gateway.PublicGateways.Foo.Paths")
if err != nil {
t.Fatalf("%s: %s", err, "Gateway.PublicGateways.Foo.Paths is a valid key in the config")
}

err = CheckKey("Gateway.PublicGateways.Foo.Bar")
if err == nil {
t.Fatal("Gateway.PublicGateways.Foo.Bar isn't a valid key in the config")
}

err = CheckKey("Plugins.Plugins.peerlog.Config.Enabled")
if err != nil {
t.Fatalf("%s: %s", err, "Plugins.Plugins.peerlog.Config.Enabled is a valid key in the config")
}
}
8 changes: 6 additions & 2 deletions docs/changelogs/v0.34.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Kubo changelog v0.34

- [v0.34.0](#v0310)
- [v0.34.0](#v0340)

## v0.34.0

- [Overview](#overview)
- [🔦 Highlights](#-highlights)
- [JSON config validation](#json-config-validation)
- [Reprovide command moved to routing](#reprovide-command-moved-to-routing)
- [Additional stats for Accelerated DHT Reprovides](#additional-stats-for-accelerated-dht-reprovides)
- [📝 Changelog](#-changelog)
Expand All @@ -15,6 +16,10 @@

### 🔦 Highlights

#### JSON config validation

`ipfs config` is now validating json fields ([#10679](https://github.com/ipfs/kubo/pull/10679)).

#### Reprovide command moved to routing

Moved the `bitswap reprovide` command to `routing reprovide`. ([#10677](https://github.com/ipfs/kubo/pull/10677))
Expand All @@ -26,4 +31,3 @@ The `stats reprovide` command now shows additional stats for the DHT Accelerated
### 📝 Changelog

### 👨‍👩‍👧‍👦 Contributors

6 changes: 6 additions & 0 deletions repo/fsrepo/fsrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,12 @@ func (r *FSRepo) SetConfigKey(key string, value interface{}) error {
return errors.New("repo is closed")
}

// Validate the key's presence in the config structure.
err := config.CheckKey(key)
if err != nil {
return err
}

// Load into a map so we don't end up writing any additional defaults to the config file.
var mapconf map[string]interface{}
if err := serialize.ReadConfigFile(r.configFilePath, &mapconf); err != nil {
Expand Down
8 changes: 4 additions & 4 deletions test/sharness/t0002-docker-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ test_expect_success "docker image build succeeds" '
'

test_expect_success "write init scripts" '
echo "ipfs config Foo Bar" > 001.sh &&
echo "ipfs config Baz Qux" > 002.sh &&
echo "ipfs config Provider.Strategy Bar" > 001.sh &&
echo "ipfs config Pubsub.Router Qux" > 002.sh &&
chmod +x 002.sh
'

Expand Down Expand Up @@ -65,10 +65,10 @@ test_expect_success "check that init scripts were run correctly and in the corre

test_expect_success "check that init script configs were applied" '
echo Bar > expected &&
docker exec "$DOC_ID" ipfs config Foo > actual &&
docker exec "$DOC_ID" ipfs config Provider.Strategy > actual &&
test_cmp actual expected &&
echo Qux > expected &&
docker exec "$DOC_ID" ipfs config Baz > actual &&
docker exec "$DOC_ID" ipfs config Pubsub.Router > actual &&
test_cmp actual expected
'

Expand Down
Loading

0 comments on commit 032ceaf

Please sign in to comment.