Repository: Cog-Creators/Red-DiscordBot

Issue:
[V3 Config] Config does not reject bad keys
Including a `.` or starting a keyname with `$` needs to be invalid in config because of mongoDB backend support. 

Config will work just fine for this on the json backend, but not with the mongodb one because these characters are not allowed in field names for mongo. Instead, it works with json, and throws uncaught exceptions on mongo.

I'd be in favor of limiting keynames to alpha-numeric and space, underscore, and hyphens. to prevent future issues like this with other future possible backends without possibly breaking cogs twice for this.


Discussion:
Seems liks `.` is fine, the only issue is if a key starts with `$`, see here: https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names.

I'm not comfortable with limiting config keys to such a small subset, there are so many places where this would warrant a redesign of config backends. Moreover it creates headaches when setting dicts as values. I'd prefer addressing this on a driver-to-driver basis and trying to escape special characters as required by each driver.
From your very link:
```
Until support is added in the query language, the use of $ and . in field names is not recommended and is not supported by the official MongoDB drivers.
```

side: I dislike per driver handling unless this is escaping, not rejecting as already stated.

Resolving patch:
diff --git a/redbot/core/drivers/red_mongo.py b/redbot/core/drivers/red_mongo.py
--- a/redbot/core/drivers/red_mongo.py
+++ b/redbot/core/drivers/red_mongo.py
@@ -1,6 +1,11 @@
+import re
+from typing import Match, Pattern
+from urllib.parse import quote_plus
+
+import motor.core
 import motor.motor_asyncio
+
 from .red_base import BaseDriver
-from urllib.parse import quote_plus
 
 __all__ = ["Mongo"]
 
@@ -80,6 +85,7 @@ def _parse_identifiers(identifiers):
     async def get(self, *identifiers: str):
         mongo_collection = self.get_collection()
 
+        identifiers = (*map(self._escape_key, identifiers),)
         dot_identifiers = ".".join(identifiers)
 
         partial = await mongo_collection.find_one(
@@ -91,10 +97,14 @@ async def get(self, *identifiers: str):
 
         for i in identifiers:
             partial = partial[i]
+        if isinstance(partial, dict):
+            return self._unescape_dict_keys(partial)
         return partial
 
     async def set(self, *identifiers: str, value=None):
-        dot_identifiers = ".".join(identifiers)
+        dot_identifiers = ".".join(map(self._escape_key, identifiers))
+        if isinstance(value, dict):
+            value = self._escape_dict_keys(value)
 
         mongo_collection = self.get_collection()
 
@@ -105,7 +115,7 @@ async def set(self, *identifiers: str, value=None):
         )
 
     async def clear(self, *identifiers: str):
-        dot_identifiers = ".".join(identifiers)
+        dot_identifiers = ".".join(map(self._escape_key, identifiers))
         mongo_collection = self.get_collection()
 
         if len(identifiers) > 0:
@@ -115,6 +125,62 @@ async def clear(self, *identifiers: str):
         else:
             await mongo_collection.delete_one({"_id": self.unique_cog_identifier})
 
+    @staticmethod
+    def _escape_key(key: str) -> str:
+        return _SPECIAL_CHAR_PATTERN.sub(_replace_with_escaped, key)
+
+    @staticmethod
+    def _unescape_key(key: str) -> str:
+        return _CHAR_ESCAPE_PATTERN.sub(_replace_with_unescaped, key)
+
+    @classmethod
+    def _escape_dict_keys(cls, data: dict) -> dict:
+        """Recursively escape all keys in a dict."""
+        ret = {}
+        for key, value in data.items():
+            key = cls._escape_key(key)
+            if isinstance(value, dict):
+                value = cls._escape_dict_keys(value)
+            ret[key] = value
+        return ret
+
+    @classmethod
+    def _unescape_dict_keys(cls, data: dict) -> dict:
+        """Recursively unescape all keys in a dict."""
+        ret = {}
+        for key, value in data.items():
+            key = cls._unescape_key(key)
+            if isinstance(value, dict):
+                value = cls._unescape_dict_keys(value)
+            ret[key] = value
+        return ret
+
+
+_SPECIAL_CHAR_PATTERN: Pattern[str] = re.compile(r"([.$]|\\U0000002E|\\U00000024)")
+_SPECIAL_CHARS = {
+    ".": "\\U0000002E",
+    "$": "\\U00000024",
+    "\\U0000002E": "\\U&0000002E",
+    "\\U00000024": "\\U&00000024",
+}
+
+
+def _replace_with_escaped(match: Match[str]) -> str:
+    return _SPECIAL_CHARS[match[0]]
+
+
+_CHAR_ESCAPE_PATTERN: Pattern[str] = re.compile(r"(\\U0000002E|\\U00000024)")
+_CHAR_ESCAPES = {
+    "\\U0000002E": ".",
+    "\\U00000024": "$",
+    "\\U&0000002E": "\\U0000002E",
+    "\\U&00000024": "\\U00000024",
+}
+
+
+def _replace_with_unescaped(match: Match[str]) -> str:
+    return _CHAR_ESCAPES[match[0]]
+
 
 def get_config_details():
     uri = None


Test patch: