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

Guard reserved tags field against incorrect use #14822

Merged
merged 31 commits into from
Jan 25, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
58ac5c0
This commit guards reserved `tags` field from setting by event parsin…
kaisecheng Dec 22, 2022
410c8b9
guard `tags` from setting a key/value map though add_field
kaisecheng Dec 22, 2022
461a34d
Merge branch 'main' of github.com:elastic/logstash into guard_reserve…
kaisecheng Dec 22, 2022
97dce2e
typo
kaisecheng Dec 22, 2022
a528472
fix unit test
kaisecheng Dec 22, 2022
9cf92f2
add flag `--event_api.tags.illegal` to control behavior of illegal va…
kaisecheng Jan 3, 2023
8348a04
add doc
kaisecheng Jan 3, 2023
f89d765
fix tests
kaisecheng Jan 4, 2023
fa1ee92
fix incorrect use of FieldReference
kaisecheng Jan 4, 2023
d37d453
rename illegal FieldReference
kaisecheng Jan 4, 2023
1e1a951
Update logstash-core/locales/en.yml
kaisecheng Jan 10, 2023
c356d9f
Update logstash-core/locales/en.yml
kaisecheng Jan 10, 2023
4d7c080
Update logstash-core/src/test/java/org/logstash/EventTest.java
kaisecheng Jan 10, 2023
f3ab6ed
Update logstash-core/src/test/java/org/logstash/EventTest.java
kaisecheng Jan 10, 2023
9fa7993
support type checking to guard against string and array of string
kaisecheng Jan 11, 2023
94607c0
Merge branch 'main' of github.com:elastic/logstash into guard_reserve…
kaisecheng Jan 11, 2023
713923c
add integration test
kaisecheng Jan 12, 2023
909526b
fix unit test
kaisecheng Jan 12, 2023
409b634
Update logstash-core/locales/en.yml
kaisecheng Jan 19, 2023
4e322d1
Update logstash-core/lib/logstash/runner.rb
kaisecheng Jan 19, 2023
3ffa3cc
Update docs/static/settings-file.asciidoc
kaisecheng Jan 19, 2023
61de3d0
Update logstash-core/src/main/java/org/logstash/Event.java
kaisecheng Jan 19, 2023
ad4259d
add FieldReference rebase
kaisecheng Jan 19, 2023
fce4f4a
update comment
kaisecheng Jan 23, 2023
cd27ed8
fix doclint
kaisecheng Jan 23, 2023
b312284
- rename illegal value to _tags instead of making a list of illegal h…
kaisecheng Jan 24, 2023
32a7d8b
Rename and clean up
kaisecheng Jan 24, 2023
05ae484
clean up rebaseOnto
kaisecheng Jan 24, 2023
11bf4aa
Update logstash-core/src/main/java/org/logstash/FieldReference.java
kaisecheng Jan 24, 2023
ff21c0d
Update docs/static/settings-file.asciidoc
kaisecheng Jan 24, 2023
5df8219
update docs
kaisecheng Jan 24, 2023
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
8 changes: 4 additions & 4 deletions docker/data/logstash/env2yaml/env2yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
// Merge environment variables into logstash.yml.
// For example, running Docker with:
//
// docker run -e pipeline.workers=6
// docker run -e pipeline.workers=6
//
// or
//
// docker run -e PIPELINE_WORKERS=6
// docker run -e PIPELINE_WORKERS=6
//
// will cause logstash.yml to contain the line:
//
// pipeline.workers: 6
//
// pipeline.workers: 6
package main

import (
Expand Down Expand Up @@ -72,6 +71,7 @@ func normalizeSetting(setting string) (string, error) {
"config.debug",
"config.support_escapes",
"config.field_reference.escape_style",
"event_api.tags.illegal",
"queue.type",
"path.queue",
"queue.page_capacity",
Expand Down
6 changes: 6 additions & 0 deletions docs/static/settings-file.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,10 @@ separating each log lines per pipeline could be helpful in case you need to trou
| `allow_superuser`
| Setting to `true` to allow or `false` to block running Logstash as a superuser.
| `true`

| `event_api.tags.illegal`
| When set to `warn`, allow illegal value assignment to the reserved `tags` field.
When set to `rename`, illegal value in `tags` will be moved to `_tags`. A tag `_tagsparsefailure` is added to `tags` field to indicate the illegal assignment.
kaisecheng marked this conversation as resolved.
Show resolved Hide resolved
This flag will be deprecated in the next major release and the `tags` field will only allow a string or an array of string.
kaisecheng marked this conversation as resolved.
Show resolved Hide resolved
| `rename`
|=======================================================================
1 change: 1 addition & 0 deletions logstash-core/lib/logstash/environment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ module Environment
Setting::TimeValue.new("config.reload.interval", "3s"), # in seconds
Setting::Boolean.new("config.support_escapes", false),
Setting::String.new("config.field_reference.escape_style", "none", true, %w(none percent ampersand)),
Setting::String.new("event_api.tags.illegal", "rename", true, %w(rename warn)),
Setting::Boolean.new("metric.collect", true),
Setting::String.new("metric.timers", "delayed", true, %w(delayed live)),
Setting::String.new("pipeline.id", "main"),
Expand Down
11 changes: 11 additions & 0 deletions logstash-core/lib/logstash/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ class LogStash::Runner < Clamp::StrictCommand
:default => LogStash::SETTINGS.get_default("config.field_reference.escape_style"),
:attribute_name => "config.field_reference.escape_style"

option ["--event_api.tags.illegal"], "STRING",
I18n.t("logstash.runner.flag.event_api.tags.illegal"),
:default => LogStash::SETTINGS.get_default("event_api.tags.illegal"),
:attribute_name => "event_api.tags.illegal"

# Module settings
option ["--modules"], "MODULES",
I18n.t("logstash.runner.flag.modules"),
Expand Down Expand Up @@ -337,6 +342,12 @@ def execute
logger.debug("Setting global FieldReference escape style: #{field_reference_escape_style}")
org.logstash.FieldReference::set_escape_style(field_reference_escape_style)

tags_illegal_setting = settings.get_setting('event_api.tags.illegal').value
if tags_illegal_setting == 'warn'
logger.warn(I18n.t("logstash.runner.tags-illegal-warning"))
kaisecheng marked this conversation as resolved.
Show resolved Hide resolved
org.logstash.Event::set_illegal_tags_action(tags_illegal_setting)
end

return start_shell(setting("interactive"), binding) if setting("interactive")

module_parser = LogStash::Modules::CLIParser.new(setting("modules_list"), setting("modules_variable_list"))
Expand Down
18 changes: 18 additions & 0 deletions logstash-core/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ en:
configtest-flag-information: |-
You may be interested in the '--configtest' flag which you can use to validate
logstash's configuration before you choose to restart a running system.
tags-illegal-warning: >-
Setting `event_api.tags.illegal` to `warn` allows illegal values in the reserved `tags` field, which may crash pipeline unexpectedly.
This flag value is deprecated and may be removed in a future release.
# YAML named reference to the logstash.runner.configuration
# so we can later alias it from logstash.agent.configuration
configuration: &runner_configuration
Expand Down Expand Up @@ -252,6 +255,21 @@ en:
HTML-style ampersand-hash encoding notation
representing decimal unicode codepoints
(`[` is `&#91;`; `]` is `&#93;`).
event_api:
tags:
illegal: |+
The top-level `tags` field is reserved, and may only contain a
single `string` or an array of `string`s -- other values will cause
subsequent access of the `tags` field to crash the pipeline.
This flag controls how the Event API handles a `tags` field that is
an illegal shape, such as a key-value map.

Available options are:
- `rename`: illegal value in `tags` will be moved to `_tags`.
A tag `_tagsparsefailure` is added to `tags` field to
indicate the illegal assignment. This is the default option.
- `warn`: allow illegal value assignment and print warning
at startup.
kaisecheng marked this conversation as resolved.
Show resolved Hide resolved
modules: |+
Load Logstash modules.
Modules can be defined using multiple instances
Expand Down
8 changes: 4 additions & 4 deletions logstash-core/spec/logstash/filters/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@ def filter(event)
}
CONFIG

sample_one("type" => "noop", "go" => "away", "tags" => {"blackhole" => "go"}) do
expect(subject.get("[tags][blackhole]")).to eq("go")
sample_one("type" => "noop", "go" => "away", "tags" => "blackhole") do
expect(subject.get("[tags]")).to eq("blackhole")
end

end
Expand All @@ -393,8 +393,8 @@ def filter(event)
}
CONFIG

sample_one("type" => "noop", "tags" => {"blackhole" => "go"}) do
expect(subject.get("[tags][blackhole]")).to eq("go")
sample_one("type" => "noop", "tags" => "blackhole") do
expect(subject.get("[tags]")).to eq("blackhole")
end
end
end
Expand Down
117 changes: 115 additions & 2 deletions logstash-core/src/main/java/org/logstash/Event.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.logstash.ObjectMappers.CBOR_MAPPER;
import static org.logstash.ObjectMappers.JSON_MAPPER;
Expand All @@ -62,9 +63,16 @@ public final class Event implements Cloneable, Queueable, co.elastic.logstash.ap
public static final String VERSION_ONE = "1";
private static final String DATA_MAP_KEY = "DATA";
private static final String META_MAP_KEY = "META";
public static final String TAGS = "tags";
public static final String TAGS_FAILURE_TAG = "_tagsparsefailure";
public static final String TAGS_FAILURE = "_tags";

enum IllegalTagsAction { RENAME, WARN }
private static IllegalTagsAction ILLEGAL_TAGS_ACTION = IllegalTagsAction.RENAME;

private static final FieldReference TAGS_FIELD = FieldReference.from(TAGS);
private static final FieldReference TAGS_FAILURE_FIELD = FieldReference.from(TAGS_FAILURE);

private static final FieldReference TAGS_FIELD = FieldReference.from("tags");

private static final Logger logger = LogManager.getLogger(Event.class);

public Event()
Expand Down Expand Up @@ -106,6 +114,15 @@ public Event(ConvertedMap data) {
}
this.cancelled = false;

// guard tags field from key/value map, only string or list is allowed
if (ILLEGAL_TAGS_ACTION == IllegalTagsAction.RENAME) {
final Object tags = Accessors.get(data, TAGS_FIELD);
if (!isLegalTagValue(tags)) {
tagFailTags(tags);
initTag(TAGS_FAILURE_TAG);
}
}

Object providedTimestamp = data.get(TIMESTAMP);
// keep reference to the parsedTimestamp for tagging below
Timestamp parsedTimestamp = initTimestamp(providedTimestamp);
Expand Down Expand Up @@ -200,6 +217,16 @@ public void setField(final String reference, final Object value) {

@SuppressWarnings("unchecked")
public void setField(final FieldReference field, final Object value) {
if (ILLEGAL_TAGS_ACTION == IllegalTagsAction.RENAME) {
if (field.equals(TAGS_FIELD) && !isLegalTagValue(value)) {
setTagsAndFailTags(value);
return;
} else if (isTagsWithMap(field)) {
setTagsAndFailTagsWithMap(field, value);
return;
}
}

switch (field.type()) {
case FieldReference.META_PARENT:
// ConvertedMap.newFromMap already does valuefication
Expand All @@ -213,6 +240,62 @@ public void setField(final FieldReference field, final Object value) {
}
}

private boolean isTagsWithMap(final FieldReference field) {
return field.getPath() != null && field.getPath().length > 0 && field.getPath()[0].equals(TAGS);
}

private boolean isLegalTagValue(final Object value) {
if (value instanceof String || value instanceof RubyString || value == null) {
return true;
} else if (value instanceof List) {
for (Object item: (List) value) {
if (!(item instanceof String) && !(item instanceof RubyString)) {
return false;
}
}
return true;
}

return false;
}


private void setTagsAndFailTags(final Object value) {
tag(TAGS_FAILURE_TAG);
tagFailTags(Valuefier.convert(value));
}

/**
* handle key/val pair in `tags`, add _tagsparsefailure to `tags`.
* rename FieldReference from `tags` to `_tags`. If existing `_tags` is a list, append value to `_tags`,
* eg. [tags][key] -> [_tags][list_index][key]
* @param field FieldReference of `tags` with map path
* @param value
*/
private void setTagsAndFailTagsWithMap(final FieldReference field, final Object value) {
Copy link
Member

Choose a reason for hiding this comment

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

I think this is doing more than it needs to, and that we should merely rename the root edge of the field from tags to _tags and let the setter work as it usually does

The semantics of Event#setField(FieldReference, Object) — the only caller of this method — are typically to overwrite the value in the target field with the provided value, although in attempting to validate that I have found an edge-case where if the field references a nesting beneath an existing non-map object we silently fail to set the value:

Suppose we have an event with a top-level field a that contains an array of strings x, y, and z:

event.set("a", ["x","y","z"])

And then we try to set the field with reference [a][b] to the value ok:

event.set("[a][b]", "ok")

This silently fails, and has since at least 7.0 😩

The existing value of top-level field a doesn't have an item at index b, so we try to create one, but fail to parse b as an int and silently abort.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason of not doing a simple rename of the root of the field is the same as the edge-case sample. _tags can fail silently by copying a map to an existing array, so to solve it and give a better user experience, I initialize _tags as a list and preserve all values.

Copy link
Member

Choose a reason for hiding this comment

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

Adding special-case logic to handle this one circumstance is going to set us up for surprises. We don't do similar for any of our other reserved fields, and don't handle this type of collision when a user is explicitly setting a particular field, so it is surprising to me that we do so here.

I would prefer to solve the underlying bug, so that Event#set(FieldReference, Object) sets the field at the specified address from FieldReference to the specified value from Object, overwriting intermediates as necessary.

This is likely a discussion for the larger team since I am sure that we have users in-the-wild who are accidentally relying on this silent failure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

_tags can fail silently by copying a map to an existing array

I referred to the first review that we simply rename the first part of FieldReference [tags][nested][field] to [_tags][nested][field] could experience the same exception that happened in tags field. Cleaning up the field by event.remove() and then copying the value should avoid type collision. I don't think we need to handle explicitly setting an incompatible type to particular field.

Currently, a normal use case is appending every illegal tags value to _tags list. If user set event.set('_tags', ['x','y','z']) and event.set("[_tags][b]", "not_ok"), it will throw exception. To address this issue, it is better to track a new issue, not necessary to fix in this PR.

To the next reviewer: here we need to decide what preserving strategy is preferred

  1. keeping all illegal values in a list form in _tags
  2. keeping only the last illegal value in _tags which aligns with other reserved fields.

Both cannot handle collision when a user is explicitly setting a particular field.

tag(TAGS_FAILURE_TAG);

List<String> paths = new ArrayList<>();
paths.add(TAGS_FAILURE);

// take list size of _tags as index
final Object failTags = Accessors.get(data, TAGS_FAILURE_FIELD);
int index = (failTags instanceof List)? ((List) failTags).size() : 0;
paths.add(Integer.toString(index));

// rebuild path & key
for(int i = 1; i < field.getPath().length; i++) {
paths.add(field.getPath()[i]);
}
Copy link
Member

Choose a reason for hiding this comment

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

Iterating from 1 is correct but could easily be interpreted as a mistake. My IDE suggested something like:

Suggested change
for(int i = 1; i < field.getPath().length; i++) {
paths.add(field.getPath()[i]);
}
List<String> oldPath = Arrays.asList(field.getPath()));
paths.addAll(oldPath.subList(1, oldPath.size());

Copy link
Contributor Author

Choose a reason for hiding this comment

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

subList from 1 is correct as this method handles Map which must contain at least one path, otherwise subList(1, path) throw index out of bound exception. for 1..length guarantees eligible index. I prefer to keep it.

if (field.getKey() != null) {
paths.add(field.getKey());
}
kaisecheng marked this conversation as resolved.
Show resolved Hide resolved

String renamedFieldRef = paths.stream().collect(Collectors.joining("][", "[", "]"));
final FieldReference renamedField = FieldReference.from(renamedFieldRef);
Copy link
Member

@yaauie yaauie Jan 19, 2023

Choose a reason for hiding this comment

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

I think that we can add a FieldReference#rebaseOnto(FieldReference) method that will handle all of this in a more testable way that tolerates fragments that can't legally be represented without escapes:

    public FieldReference rebaseOnto(final FieldReference newBase) {
        if (type == META_PARENT) { return newBase; }
        
        final List<String> modifiedPath = new ArrayList<>();
        if (newBase.type == META_CHILD) { modifiedPath.add(Event.METADATA); }
        
        modifiedPath.addAll(Arrays.asList(newBase.path));
        modifiedPath.add(newBase.key);
        
        modifiedPath.addAll(Arrays.asList(path).subList(1, path.length));
        modifiedPath.add(key);

        return FieldReference.fromTokens(modifiedPath);
    }

[EDIT: my initial suggestion missed adding newbase.key 🤦🏼 ]

Doing so requires that we break FieldReference.parse into two methods:

--- a/logstash-core/src/main/java/org/logstash/FieldReference.java
+++ b/logstash-core/src/main/java/org/logstash/FieldReference.java
@@ -244,7 +268,10 @@ public final class FieldReference {
         final List<String> path = TOKENIZER.tokenize(reference).stream()
                 .map(ESCAPE_HANDLER::unescape)
                 .collect(Collectors.toList());
+        return fromTokens(path);
+    }
 
+    private static FieldReference fromTokens(final List<String> path) {
         final String key = path.remove(path.size() - 1);
         final boolean empty = path.isEmpty();
         if (empty && key.equals(Event.METADATA)) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@yaauie The rebase idea works well when a single root path is the rebase target.
eg. newBase.path == [_tags]

newBase = FieldReference.from("_tags")
oldBase.rebaseOnto(newBase)

However, when newBase path is more than one, newBase.path == [_tags, 0], it again needs to construct a string [_tags][0] to create newBase newBase = FieldReference.from("[_tags][0]")

Can we make rebaseOnto() accept newBase.path?

    public FieldReference rebaseOnto(final List<String> basePath) {
        final List<String> modifiedPath = new ArrayList<>();
        modifiedPath.addAll(basePath);

        if (path.length > 1) { 
            modifiedPath.addAll(Arrays.asList(path).subList(1, path.length)); 
        }

        modifiedPath.add(key);

        return FieldReference.fromTokens(modifiedPath);
    }

Copy link
Member

Choose a reason for hiding this comment

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

My primary concern with using FieldReference#from with a manually-constructed string is that if any component of the path contains reserved characters, the result cannot be parsed and an exception will be thrown, which means including arbitrary field names from the input can lead to runtime exceptions.

When index is an int, FieldReference#from(String.format("[_tags][%s]", index)) cannot possibly be invalid.

And FieldReference.from(String) is cached, so it is super cheap especially in cases where we use the same input multiple times which we are likely to do with the only variant being numeric indices.


I started with a List<String>, but there are weird edge-cases of how @metadata fields work that made me suggest taking a FieldReference to rebase onto:

canonical path key type note
[deeply][nested][field] ["deeply", "nested"] "field" DATA_CHILD normal
[@metadata][deeply][nested][field] ["deeply", "nested"] "field" METADATA_CHILD metadata is only type, not present in path
[@metadata] [] "@metadata" METADATA_PARENT huh? metadata is both a type and a key

Even though we are only using this new FieldReference#rebaseOnto with known base that is not @metadata, I didn't want to introduce a new API as a trap that fails weirdly when someone starts using it in new ways.

HOWEVER my suggestion provides no way to rebase onto the data-root of the event, which can be done by providing an empty list with your implementation.

I think something like the following satisfies the @metadata weirdness by extracting the complexity of reconstructing the list of tokens, and also gives us the freedom moving forward to use this for other purposes:

    /**
     * @param shiftLevel the number of levels to shift off of this {@code FieldReference}.
     *                   A value of {@code 0} will _move_ the entire nesting onto the new base
     *                   A value of {@code 1} will remove 1 top-level nesting before moving the remainder
     *                   Specifying more levels than available nestings is an error condition.
     * @param newBase a sequence of zero or more field nestings to prepend
     * @return
     */
    public FieldReference rebaseOnto(@Nonnegative int shiftLevel, final List<String> newBase) {
        if (shiftLevel == 0 && newBase.isEmpty()) { return this; }

        List<String> tokens = toTokens();
        if (tokens.size() <= shiftLevel) {
            throw new IndexOutOfBoundsException(String.format("cannot shift %s levels from field reference with %s tokens", shiftLevel, tokens.size()));
        }

        final List<String> modifiedPath = new ArrayList<>(newBase.size() + tokens.size() - shiftLevel);
        modifiedPath.addAll(newBase);
        modifiedPath.addAll(tokens.subList(shiftLevel, tokens.size()));

        return FieldReference.fromTokens(modifiedPath);
    }

    /**
     * @return a list of tokens that can round-trip through {@link FieldReference#fromTokens}
     *         to produce an identical {@code FieldReference}.
     */
    private List<String> toTokens() {
        // min size to avoid resizing: maybe_meta + path + key
        final List<String> tokens = new ArrayList<>(path.length + 2);

        if (type == META_CHILD) { tokens.add(Event.METADATA); }
        tokens.addAll(Arrays.asList(path));
        tokens.add(key);

        return tokens;
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@metadata is indeed weird. rebaseOnto() accepting path gives much better flexibility 👍

Accessors.set(data, renamedField, Valuefier.convert(value));
}

@Override
public boolean includes(final String field) {
return includes(FieldReference.from(field));
Expand Down Expand Up @@ -483,6 +566,28 @@ private void appendTag(final List<String> tags, final String tag) {
}
}

@SuppressWarnings("unchecked")
private void tagFailTags(final Object tag) {
final Object failTags = Accessors.get(data, TAGS_FAILURE_FIELD);
if (failTags == null) {
final List<Object> list = new ArrayList<>(1);
appendFailTags(list, tag);
} else {
if (failTags instanceof List) {
appendFailTags((List<Object>) failTags, tag);
} else {
final List<Object> list = new ArrayList<>(2);
list.add(failTags);
appendFailTags(list, tag);
}
}
}

private void appendFailTags(final List<Object> failTags, final Object failTag) {
failTags.add(failTag);
Accessors.set(data, TAGS_FAILURE_FIELD, ConvertedList.newFromList(failTags));
}

/**
* Fallback for {@link Event#tag(String)} in case "tags" was populated by just a String value
* and needs to be converted to a list before appending to it.
Expand All @@ -495,6 +600,14 @@ private void scalarTagFallback(final String existing, final String tag) {
appendTag(tags, tag);
}

public static void setIllegalTagsAction(final String action) {
ILLEGAL_TAGS_ACTION = IllegalTagsAction.valueOf(action.toUpperCase());
}

public static IllegalTagsAction getIllegalTagsAction() {
return ILLEGAL_TAGS_ACTION;
}

@Override
public byte[] serialize() throws JsonProcessingException {
final Map<String, Map<String, Object>> map = new HashMap<>(2, 1.0F);
Expand Down
Loading