Skip to content
Open
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
36 changes: 31 additions & 5 deletions pkg/acquisition/modules/docker/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@

type mockDockerCli struct {
client.Client
services []dockerTypesSwarm.Service
services []dockerTypesSwarm.Service
}

// Simplified Info method - just return basic info without complex types
Expand All @@ -108,7 +108,7 @@
items := cli.services

if items == nil {
defaultTestService := dockerTypesSwarm.Service{
defaultTestService := dockerTypesSwarm.Service{
ID: "service123",
Spec: dockerTypesSwarm.ServiceSpec{
Annotations: dockerTypesSwarm.Annotations{
Expand Down Expand Up @@ -522,8 +522,8 @@
func (*mockDockerCli) ContainerInspect(_ context.Context, _ string, _ client.ContainerInspectOptions) (client.ContainerInspectResult, error) {
res := client.ContainerInspectResult{}
res.Container = dockerContainer.InspectResponse{
Config: &dockerContainer.Config{ Tty: false },
State: &dockerContainer.State{ Running: true }, // Mock container is running
Config: &dockerContainer.Config{Tty: false},
State: &dockerContainer.State{Running: true}, // Mock container is running
}

return res, nil
Expand All @@ -542,14 +542,15 @@

return service, []byte("{}"), nil
}

// Since we are mocking the docker client, we return channels that will never be used
func (*mockDockerCli) Events(_ context.Context, _ client.EventsListOptions) client.EventsResult {
eventsChan := make(chan dockerTypesEvents.Message)
errChan := make(chan error)

return client.EventsResult{
Messages: eventsChan,
Err: errChan,
Err: errChan,
}
}

Expand Down Expand Up @@ -658,3 +659,28 @@
})
}
}

func TestParseLabelsNestedCollisionDoesNotPanic(t *testing.T) {
// A leaf label and a branch label under the same key (set by whoever
// launches the container) must not panic the type assertion in
// parseKeyToMap. Only one of the two can win, but the result must be the
// same every run (not decided by random map order) and unrelated labels
// must survive.
labels := map[string]string{
"crowdsec.enable": "true",
"crowdsec.enable.foo": "bar",
"crowdsec.other": "keepme",
}

// Sorting inside parseLabels applies the shorter "crowdsec.enable" first,
// then the more specific "crowdsec.enable.foo" overwrites it, so the branch
// wins deterministically. Run it repeatedly to catch any order dependence.
var first map[string]any
require.NotPanics(t, func() { first = parseLabels(labels) })
assert.Equal(t, "keepme", first["other"])
assert.Equal(t, map[string]any{"foo": "bar"}, first["enable"])

for i := 0; i < 50; i++ {

Check failure on line 683 in pkg/acquisition/modules/docker/docker_test.go

View workflow job for this annotation

GitHub Actions / Build + tests

for loop can be changed to use an integer range (Go 1.22+) (intrange)

Check failure on line 683 in pkg/acquisition/modules/docker/docker_test.go

View workflow job for this annotation

GitHub Actions / Build + tests

for loop can be changed to use an integer range (Go 1.22+) (intrange)
assert.Equal(t, first, parseLabels(labels))
}
}
29 changes: 24 additions & 5 deletions pkg/acquisition/modules/docker/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,23 @@ import (

func parseLabels(labels map[string]string) map[string]interface{} {
result := make(map[string]interface{})
for key, value := range labels {
parseKeyToMap(result, key, value)

// Iterate in sorted key order so the result is deterministic. Go map
// iteration order is random, and when a leaf and a branch collide under the
// same key (e.g. crowdsec.enable and crowdsec.enable.foo) only one can win;
// sorting means the shorter, less specific key is always applied first and
// then overwritten by the more specific one, instead of the winner being
// decided at random from run to run.
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
slices.Sort(keys)

for _, key := range keys {
parseKeyToMap(result, key, labels[key])
}

return result
}

Expand All @@ -28,10 +42,15 @@ func parseKeyToMap(m map[string]interface{}, key string, value string) {
}

for i := 1; i < len(parts)-1; i++ {
if _, ok := m[parts[i]]; !ok {
m[parts[i]] = make(map[string]interface{})
next, ok := m[parts[i]].(map[string]interface{})
if !ok {
// The key is absent, or a leaf value (e.g. a sibling label like
// crowdsec.enable=true) is already stored here. Replace it with a
// nested map instead of panicking on the type assertion.
next = make(map[string]interface{})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think there's a chance of skipping some values depending on the order of the elements in the map (which is random) ?

We probably want iterate over a sorted slice of all labels to avoid potential overwrite.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, you're right that the random map order made this non-deterministic. I pushed a change to parseLabels that sorts the keys before building the tree, so the shorter key (crowdsec.enable) is always applied before the more specific one (crowdsec.enable.foo).

With genuinely colliding labels only one can survive, since enable can't be both a string and a nested map at once, but now the branch wins on every run instead of it being a coin flip on iteration order. I strengthened the test to assert the branch wins and to run parseLabels repeatedly so any remaining order dependence would fail it.

m[parts[i]] = next
}
m = m[parts[i]].(map[string]interface{})
m = next
}
m[parts[len(parts)-1]] = value
}
Loading