Repository: ValvePython/steam

Issue:
Option to return a2s_rules as bytes instead of strings
The `steam.game_servers.a2s_rules` function currently returns the server rules as a dictionary of strings to strings (`Dict[str, str]`), which is suitable for plain text rules. However, some game servers use the Steam `A2S_RULES` API to return binary data. For example, Bohemia Interactive's Arma and DayZ servers use the rules API to return required DLC and Mod information (see https://community.bistudio.com/wiki/Arma_3:_ServerBrowserProtocol3 for details).

Unfortunately, because `steam.game_servers.a2s_rules` decodes the A2S_RULES server response data as UTF-8 **with `"replace"` as the `errors` option**, important binary data is lost when querying an Arma or DayZ server's rules.

For example:
```python
>>> pprint(a2s_rules(("192.169.82.235",2303)))
{'\x01\x01': '\x01\x01\x01\x02\x01\x02\x01\x02\x02���_\x04ITv�\x0eNamalsk '
             'Island#�:\x13Ԓ\x11\x0b'
             'Livonia DLC\x03\x0eCrimsonZamboni\x04dayz\x06sumrak',
 'allowedBuild': 0,
 'dedicated': 1,
 'island': 'namalsk',
 'language': 65545,
 'platform': 'win',
 'requiredBuild': 0,
 'requiredVersion': 113,
 'timeLeft': 15}
```

It would be very helpful if we could get a server's `A2S_RULES` information as raw `bytes`, instead of `str` objects. Perhaps an optional keyword argument could be added to the `steam.game_servers.a2s_rules` function? For example:

```python
a2s_rules(("192.169.82.235",2303), binary=True)
```

The default should be `binary=False` to maintain backwards compatibility but when `binary=True`, the resulting rule names and values would be returned as `bytes` objects (i.e. `Dict[bytes, bytes]`), allowing the caller to further decode the binary data, as needed.

Comments:

Patch:
diff --git a/requirements.txt b/requirements.txt
index 92661a9..14293fb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,8 @@ pycryptodomex>=3.7.0
 requests>=2.9.1
 vdf>=3.3
 gevent>=1.3.0
-protobuf>=3.0.0
+protobuf>~3.0; python_version >= '3'
+protobuf<3.18.0; python_version < '3'
 gevent-eventemitter~=2.1
 cachetools>=3.0.0
 enum34==1.1.2; python_version < '3.4'
diff --git a/setup.py b/setup.py
index 16b8870..382f8f2 100644
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,8 @@ install_requires = [
 install_extras = {
     'client': [
         'gevent>=1.3.0',
-        'protobuf>=3.0.0',
+        'protobuf>~3.0; python_version >= "3"',
+        'protobuf<3.18.0; python_version < "3"',
         'gevent-eventemitter~=2.1',
     ],
 }
diff --git a/steam/game_servers.py b/steam/game_servers.py
index 07b09f1..80bf108 100644
--- a/steam/game_servers.py
+++ b/steam/game_servers.py
@@ -142,8 +142,11 @@ def _u(data):
 
 
 class StructReader(_StructReader):
-    def read_cstring(self):
-        return _u(super(StructReader, self).read_cstring())
+    def read_cstring(self, binary=False):
+        raw = super(StructReader, self).read_cstring()
+        if binary:
+            return raw
+        return _u(raw)
 
 
 class MSRegion(IntEnum):
@@ -526,7 +529,7 @@ def a2s_players(server_addr, timeout=2, challenge=0):
     return players
 
 
-def a2s_rules(server_addr, timeout=2, challenge=0):
+def a2s_rules(server_addr, timeout=2, challenge=0, binary=False):
     """Get rules from server
 
     :param server_addr: (ip, port) for the server
@@ -535,9 +538,11 @@ def a2s_rules(server_addr, timeout=2, challenge=0):
     :type  timeout: float
     :param challenge: (optional) challenge number
     :type  challenge: int
+    :param binary: (optional) return rules as raw bytes
+    :type  binary: bool
     :raises: :class:`RuntimeError`, :class:`socket.timeout`
     :returns: a list of rules
-    :rtype: :class:`list`
+    :rtype: :class:`dict`
     """
     ss = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
     ss.connect(server_addr)
@@ -571,13 +576,14 @@ def a2s_rules(server_addr, timeout=2, challenge=0):
     rules = {}
 
     while len(rules) != num_rules:
-        name = data.read_cstring()
-        value = data.read_cstring()
-
-        if _re_match(r'^\-?[0-9]+$', value):
-            value = int(value)
-        elif _re_match(r'^\-?[0-9]+\.[0-9]+$', value):
-            value = float(value)
+        name = data.read_cstring(binary=binary)
+        value = data.read_cstring(binary=binary)
+
+        if not binary:
+            if _re_match(r'^\-?[0-9]+$', value):
+                value = int(value)
+            elif _re_match(r'^\-?[0-9]+\.[0-9]+$', value):
+                value = float(value)
 
         rules[name] = value
 


Test patch:
diff --git a/tests/test_game_servers.py b/tests/test_game_servers.py
new file mode 100644
index 0000000..47fc89e
--- /dev/null
+++ b/tests/test_game_servers.py
@@ -0,0 +1,64 @@
+import mock
+import socket
+import unittest
+
+from steam.game_servers import a2s_rules
+
+
+class TestA2SRules(unittest.TestCase):
+    @mock.patch("socket.socket")
+    def test_returns_rules_with_default_arguments(self, mock_socket_class):
+        mock_socket = mock_socket_class.return_value
+        mock_socket.recv.side_effect = [
+            b"\xff\xff\xff\xffA\x01\x02\x03\x04",
+            b"\xff\xff\xff\xffE\x03\0text\0b\x99r\0int\x0042\0float\x0021.12\0"
+        ]
+
+        rules = a2s_rules(("addr", 1234))
+
+        self.assertEqual(
+            {
+                "text": u"b\ufffdr",
+                "int": 42,
+                "float": 21.12
+            },
+            rules)
+
+        mock_socket_class.assert_called_once_with(
+            socket.AF_INET, socket.SOCK_DGRAM)
+
+        mock_socket.connect.assert_called_once_with(("addr", 1234))
+        mock_socket.settimeout.assert_called_once_with(2)
+
+        self.assertEqual(2, mock_socket.send.call_count)
+        mock_socket.send.assert_has_calls([
+            mock.call(b"\xff\xff\xff\xffV\0\0\0\0"),
+            mock.call(b"\xff\xff\xff\xffV\x01\x02\x03\x04")
+        ])
+
+        self.assertEqual(2, mock_socket.recv.call_count)
+        mock_socket.recv.assert_has_calls([
+            mock.call(512),
+            mock.call(2048)
+        ])
+
+        mock_socket.close.assert_called_once_with()
+
+    @mock.patch("socket.socket")
+    def test_returns_rules_as_bytes_when_binary_is_true(
+            self, mock_socket_class):
+        mock_socket = mock_socket_class.return_value
+        mock_socket.recv.side_effect = [
+            b"\xff\xff\xff\xffA\x01\x02\x03\x04",
+            b"\xff\xff\xff\xffE\x03\0text\0b\x99r\0int\x0042\0float\x0021.12\0"
+        ]
+
+        rules = a2s_rules(("addr", 1234), binary=True)
+
+        self.assertEqual(
+            {
+                b"text": b"b\x99r",
+                b"int": b"42",
+                b"float": b"21.12"
+            },
+            rules)