File: _decoder.py

package info (click to toggle)
freeorion 0.5.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 194,940 kB
  • sloc: cpp: 186,508; python: 40,969; ansic: 1,164; xml: 719; makefile: 32; sh: 7
file content (220 lines) | stat: -rw-r--r-- 8,103 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""This module defines the decoding for the FreeOrion AI savegames.

The decoder is subclassed from the standard library json decoder and
uses its string parsing. However, the resulting objects will be interpreted
differently according to the encoding used in FreeOrion AI savegames.

The decoder will only load trusted classes as defined in _definitions.py,
if an unknown/untrusted object is encountered, it will raise a InvalidSaveGameException.

When classes are loaded, their __setstate__ method will be invoked if available or
the __dict__ content will be set directly. It is the responsiblity of the trusted classes
to provide a __setstate__ method to verify and possibly sanitize the content of the passed state.
"""
import binascii
import json
from typing import Union

import EnumsAI
from AIstate import AIstate

from ._definitions import (
    ENUM_PREFIX,
    FALSE,
    FLOAT_PREFIX,
    INT_PREFIX,
    NONE,
    PLACEHOLDER,
    SET_PREFIX,
    TRUE,
    TUPLE_PREFIX,
    InvalidSaveGameException,
    trusted_classes,
)


class SaveDecompressException(Exception):
    """
    Exception class for troubles with decompressing save game string.
    """


def _starts_with_prefix(prefix: str, candidate: str) -> bool:
    return candidate.startswith(prefix)


def _extract_value(prefix: str, value: str):
    return value[len(prefix) :]


def _extract_collection(prefix: str, value: str):
    return value[len(prefix) + 1 : -1]


def load_savegame_string(string: Union[str, bytes]) -> AIstate:
    """
    :raises: SaveDecompressException, InvalidSaveGameException
    """
    import base64
    import zlib

    try:
        new_string = base64.b64decode(string)
    except (binascii.Error, ValueError, TypeError) as e:
        raise SaveDecompressException("Fail to decode base64 savestate %s" % e) from e
    try:
        new_string = zlib.decompress(new_string)
    except zlib.error as e:
        raise SaveDecompressException("Fail to decompress savestate %s" % e) from e
    return decode(new_string.decode("utf-8"))


def decode(obj):
    return _FreeOrionAISaveGameDecoder().decode(obj)


class _FreeOrionAISaveGameDecoder(json.JSONDecoder):
    def __init__(self, **kwargs):
        # do not allow control characters
        super().__init__(strict=True, **kwargs)

    def decode(self, s, _w=None):
        # use the default JSONDecoder to parse the string into a dict
        # then interpret the dict content according to our encoding
        retval = super().decode(s)
        return self.__interpret(retval)

    def __interpret_dict(self, obj):
        # if the dict does not contain the class-encoding keys,
        # then it is a standard dictionary.
        if not all(key in obj for key in ("__class__", "__module__")):
            return {self.__interpret(key): self.__interpret(value) for key, value in obj.items()}

        # pop and verify class and module name, then parse the class content
        class_name = obj.pop("__class__")
        module_name = obj.pop("__module__")
        full_name = f"{module_name}.{class_name}"
        cls = trusted_classes.get(full_name)
        if cls is None:
            raise InvalidSaveGameException("DANGER DANGER - %s not trusted" % full_name)

        parsed_content = self.__interpret_dict(obj)

        # create a new instance without calling the actual __new__or __init__
        # function of the class (so we avoid any side-effects from those)
        new_instance = object.__new__(cls)

        # Set the content trying to use the __setstate__ method if defined
        # Otherwise, directly set the dict content.
        try:
            setstate = new_instance.__setstate__
        except AttributeError:
            if not type(parsed_content) == dict:  # noqa: E721
                raise InvalidSaveGameException("Could not set content for %s" % new_instance)
            new_instance.__dict__ = parsed_content
        else:
            # only call now to not catch exceptions in the setstate method
            setstate(parsed_content)
        return new_instance

    def __interpret(self, x):  # noqa: C901
        """Interpret an object that was just decoded."""
        # primitive types do not have to be interpreted
        if isinstance(x, (int, float)):
            return x

        # special handling for dicts as they could encode our classes
        if isinstance(x, dict):
            return self.__interpret_dict(x)

        # for standard containers, interpret each element
        if isinstance(x, list):
            return list(self.__interpret(element) for element in x)

        # if it is a string, check if it encodes another data type
        if isinstance(x, str):
            # does it encode an integer?
            if _starts_with_prefix(INT_PREFIX, x):
                x = _extract_value(INT_PREFIX, x)
                return int(x)

            # does it encode a float?
            if _starts_with_prefix(FLOAT_PREFIX, x):
                x = _extract_value(FLOAT_PREFIX, x)
                return float(x)

            # does it encode a tuple?
            if _starts_with_prefix(TUPLE_PREFIX, x):
                # ignore surrounding parentheses
                content = _extract_collection(TUPLE_PREFIX, x)
                content = _replace_quote_placeholders(content)
                result = self.decode(content)
                return tuple(result)

            # does it encode a set?
            if _starts_with_prefix(SET_PREFIX, x):
                # ignore surrounding parentheses
                content = _extract_collection(SET_PREFIX, x)
                content = _replace_quote_placeholders(content)
                result = self.decode(content)
                return set(result)

            # does it encode an enum?
            if _starts_with_prefix(ENUM_PREFIX, x):
                full_name = _extract_value(ENUM_PREFIX, x)
                partial_names = full_name.split(".")
                if not len(partial_names) == 2:
                    raise InvalidSaveGameException("Could not decode Enum %s" % x)
                enum_name = partial_names[0]
                enum = getattr(EnumsAI, enum_name, None)
                if enum is None:
                    raise InvalidSaveGameException("Invalid enum %s" % enum_name)
                retval = getattr(enum, partial_names[1], None)
                if retval is None:
                    raise InvalidSaveGameException("Invalid enum value %s" % full_name)
                return retval

            if _starts_with_prefix(TRUE, x):
                return True

            if _starts_with_prefix(FALSE, x):
                return False

            if _starts_with_prefix(NONE, x):
                return None

            # no special cases apply at this point, should be a standard string
            return x

        raise TypeError(f"Unexpected type {type(x)} ({x})")


def _replace_quote_placeholders(s):
    """Replace PLACEHOLDER with quotes if not nested within another encoded container.

    To be able to use tuples as dictionary keys, to use standard json decoder,
    the entire tuple with its content must be encoded as a single string.
    The inner objects may no longer be quoted as that would prematurely terminate
    the strings. Inner quotes are therefore replaced with the PLACEHOLDER char.

    Example:
        output = encode(tuple(["1", "string"]))
        "__TUPLE__([$1$, $string$])"

    To be able to decode the inner content, the PLACEHOLDER must be converted
    to quotes again.
    """
    n = 0  # counts nesting level (i.e. number of opened but not closed parentheses)
    start = 0  # starting point for string replacement
    for i in range(len(s)):
        if s[i] == "(":
            # if this is an outer opening parenthesis, then replace placeholder from last parenthesis to here
            if n == 0:
                s = s[:start] + s[start:i].replace(PLACEHOLDER, '"') + s[i:]
            n += 1
        elif s[i] == ")":
            n -= 1
            if n == 0:
                start = i
    s = s[:start] + s[start:].replace(PLACEHOLDER, '"')
    return s