Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Issue #9364 JSON config validation #10679

Merged
merged 32 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c5649c2
Fix JSON config validation
gsergey418alt Jan 28, 2025
b69676b
Add changelog entry.
gsergey418alt Jan 28, 2025
42692c6
Fix test
gsergey418alt Jan 28, 2025
f589555
Fix more tests, 53 still failing
gsergey418alt Jan 28, 2025
c35ee66
Remove JSON test due to fragilty, fix some more tests, Lib tests left.
gsergey418alt Jan 28, 2025
dd9627e
Remove lib tests as they don't seem to be relevant anymore.
gsergey418alt Jan 28, 2025
f7b44d0
Move changelog into v0.34
gsergey418alt Jan 28, 2025
11f7f06
Fix config validation.
gsergey418alt Jan 29, 2025
ef3ff89
Delete test file
gsergey418alt Jan 29, 2025
7b88c81
Use reflection to ignore encoding/json tags.
gsergey418alt Jan 29, 2025
2765ec0
Add exceptions for fields that were already maps in the config, fix o…
gsergey418alt Jan 29, 2025
354256a
Fix last (hopefully) test
gsergey418alt Jan 29, 2025
12a0185
test: fix the socat tests after the ubuntu 24.04 upgrade
galargh Jan 29, 2025
c546382
Merge branch 'socat-ubuntu-noble' of https://github.com/ipfs/kubo int…
gsergey418alt Jan 29, 2025
a091fbe
Merge branch 'master' into fix/9364-validation
guillaumemichel Jan 29, 2025
ab2657e
Merge branch 'master' into fix/9364-validation
guillaumemichel Jan 30, 2025
af0c5c6
docker sharness
guillaumemichel Jan 30, 2025
7a3732f
updated ReflectToMap
guillaumemichel Jan 31, 2025
9086971
Fix nested maps and write a test for it.
gsergey418alt Jan 31, 2025
564d9dd
Add tests for other data types
gsergey418alt Jan 31, 2025
c34c068
Revert "Fix nested maps and write a test for it."
gsergey418alt Feb 3, 2025
44fbd88
Validate structs nested in maps.
gsergey418alt Feb 3, 2025
8a669cd
Add comments.
gsergey418alt Feb 3, 2025
0a25331
Remove debug
gsergey418alt Feb 3, 2025
eae4382
Add test for PublicGateways
gsergey418alt Feb 3, 2025
7e8d3ca
Add test for sturct in map
gsergey418alt Feb 3, 2025
db3cd49
Merge branch 'master' into fix/9364-validation
guillaumemichel Feb 4, 2025
bed120f
comment typo
guillaumemichel Feb 4, 2025
69235e2
slight refactor
guillaumemichel Feb 4, 2025
8e72a65
Add CheckKey tests.
gsergey418alt Feb 4, 2025
87e42d8
Add handling for interface{} in the config
gsergey418alt Feb 4, 2025
7d7633e
"any" -> nil
gsergey418alt Feb 4, 2025
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
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 @@
"fmt"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/ipfs/kubo/misc/fsutil"
Expand Down Expand Up @@ -137,6 +138,71 @@
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 "any"
}

// 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())
}

Check warning on line 182 in config/config.go

View check run for this annotation

Codecov / codecov/patch

config/config.go#L178-L182

Added lines #L178 - L182 were not covered by tests
// 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())
}

Check warning on line 194 in config/config.go

View check run for this annotation

Codecov / codecov/patch

config/config.go#L193-L194

Added lines #L193 - L194 were not covered by tests
return result

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

Check warning on line 202 in config/config.go

View check run for this annotation

Codecov / codecov/patch

config/config.go#L202

Added line #L202 was not covered by tests
}
}

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

return &newConfig, nil
}

// Check if the provided key is present in the structure.
func CheckKey(key string) error {
guillaumemichel marked this conversation as resolved.
Show resolved Hide resolved
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 v, ok := cursor.(string); ok && v == "any" {
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gsergey418alt would that work? Using "any" as key seems strange.

Suggested change
if v, ok := cursor.(string); ok && v == "any" {
return nil
}
if cursor == nil {
return nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm using it as a value here, try printing json.Marshal(confmap)

Copy link
Contributor

@guillaumemichel guillaumemichel Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean?

I just think using the string "any" is strange. Would it work to test for a nil pointer or empty string instead? And adapting ReflectToMap accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are other empty strings in the output of ReflectToMap. Nil pointer could work, but "any" is perhaps more expressive e. g. this key contain anything, stop validating. I can change it to nil ptr if you want to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NVM, empty strings and nil values would also work

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NVM, It wouldn't work with empty strings, but it would still fail at a later stage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed "any" to nil

path := strings.Join(parts[:i], ".")
return fmt.Errorf("%s key is not a map", path)

Check warning on line 241 in config/config.go

View check run for this annotation

Codecov / codecov/patch

config/config.go#L240-L241

Added lines #L240 - L241 were not covered by tests
}

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 @@
return errors.New("repo is closed")
}

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

Check warning on line 683 in repo/fsrepo/fsrepo.go

View check run for this annotation

Codecov / codecov/patch

repo/fsrepo/fsrepo.go#L682-L683

Added lines #L682 - L683 were not covered by tests

// 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
Loading