yace-codec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yace_codec/__init__.py +71 -0
- yace_codec/__main__.py +6 -0
- yace_codec/cli.py +276 -0
- yace_codec/codec.py +498 -0
- yace_codec/errors.py +65 -0
- yace_codec/keystore.py +554 -0
- yace_codec/py.typed +0 -0
- yace_codec/tables.py +324 -0
- yace_codec-0.1.0.dist-info/METADATA +701 -0
- yace_codec-0.1.0.dist-info/RECORD +14 -0
- yace_codec-0.1.0.dist-info/WHEEL +5 -0
- yace_codec-0.1.0.dist-info/entry_points.txt +2 -0
- yace_codec-0.1.0.dist-info/licenses/LICENSE +21 -0
- yace_codec-0.1.0.dist-info/top_level.txt +1 -0
yace_codec/codec.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""The YACE encoding pipeline.
|
|
2
|
+
|
|
3
|
+
Encoding ``text`` with family ``name``, code length ``L`` and ``I`` iterations:
|
|
4
|
+
|
|
5
|
+
1. Layer ``k`` (``k = 1..I``) replaces every character with its code from table
|
|
6
|
+
``name-L:k``. Each space gets one of several space codes at random. Codes
|
|
7
|
+
consist of letters and digits, so every layer's output is valid input for
|
|
8
|
+
the next layer.
|
|
9
|
+
2. ``ceil(chunks / 4)`` bin (noise) codes from the last table are inserted at
|
|
10
|
+
random chunk positions, where a chunk is one ``L``-character code.
|
|
11
|
+
3. Chunk ``x - 1`` is swapped with chunk ``n - x`` for ``x = L, 2L, 3L, ...``.
|
|
12
|
+
No two swaps overlap, so applying the step twice restores the order.
|
|
13
|
+
4. The token is the header ``name-L:I``, a newline, and the chunks.
|
|
14
|
+
|
|
15
|
+
Decoding undoes the steps in reverse order with the same tables. This is the
|
|
16
|
+
format of the original YACE scripts, so their tokens and ``encodings.json``
|
|
17
|
+
files keep working.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import binascii
|
|
24
|
+
import codecs
|
|
25
|
+
import os
|
|
26
|
+
import secrets
|
|
27
|
+
from random import Random
|
|
28
|
+
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from .errors import DecodingError, EncodingError, UnsupportedCharacterError
|
|
31
|
+
from .keystore import KeyStore
|
|
32
|
+
from .tables import (
|
|
33
|
+
CODE_ALPHABET,
|
|
34
|
+
MAX_LENGTH,
|
|
35
|
+
MIN_LENGTH,
|
|
36
|
+
SPACE_KEY,
|
|
37
|
+
SUPPORTED_CHARACTERS,
|
|
38
|
+
Table,
|
|
39
|
+
parse_table_name,
|
|
40
|
+
table_name,
|
|
41
|
+
validate_family,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DEFAULT_FAMILY",
|
|
46
|
+
"DEFAULT_ITERATIONS",
|
|
47
|
+
"DEFAULT_LENGTH",
|
|
48
|
+
"MAX_ENCODED_CHARS",
|
|
49
|
+
"MAX_ITERATIONS",
|
|
50
|
+
"Codec",
|
|
51
|
+
"Header",
|
|
52
|
+
"decode",
|
|
53
|
+
"decode_bytes",
|
|
54
|
+
"encode",
|
|
55
|
+
"encode_bytes",
|
|
56
|
+
"parse_header",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
#: Family used when no name is given.
|
|
60
|
+
DEFAULT_FAMILY = "default"
|
|
61
|
+
#: Default code length (``hash_length`` in the original scripts).
|
|
62
|
+
DEFAULT_LENGTH = 2
|
|
63
|
+
#: Default number of encoding layers (``final_hash_iteration`` in the original scripts).
|
|
64
|
+
DEFAULT_ITERATIONS = 3
|
|
65
|
+
#: Upper limit for ``iterations``.
|
|
66
|
+
MAX_ITERATIONS = 32
|
|
67
|
+
#: Encoding is refused when ``len(text) * length ** iterations`` exceeds this.
|
|
68
|
+
#: Encoding and decoding need roughly 25-40 bytes of memory per token character.
|
|
69
|
+
MAX_ENCODED_CHARS = 2**25
|
|
70
|
+
|
|
71
|
+
_CODE_CHARS = frozenset(CODE_ALPHABET)
|
|
72
|
+
_SYSTEM_RANDOM = secrets.SystemRandom()
|
|
73
|
+
# UTF-32 first: its little-endian BOM starts with the UTF-16 one.
|
|
74
|
+
_BOMS = (
|
|
75
|
+
(codecs.BOM_UTF32_LE, "utf-32"),
|
|
76
|
+
(codecs.BOM_UTF32_BE, "utf-32"),
|
|
77
|
+
(codecs.BOM_UTF8, "utf-8-sig"),
|
|
78
|
+
(codecs.BOM_UTF16_LE, "utf-16"),
|
|
79
|
+
(codecs.BOM_UTF16_BE, "utf-16"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
KeyStoreLike = Union[KeyStore, str, "os.PathLike[str]", None]
|
|
83
|
+
TokenLike = Union[str, bytes, bytearray]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Header(NamedTuple):
|
|
87
|
+
"""The first line of a token: ``<family>-<length>:<iterations>``."""
|
|
88
|
+
|
|
89
|
+
family: str
|
|
90
|
+
length: int
|
|
91
|
+
iterations: int
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def table_names(self) -> Tuple[str, ...]:
|
|
95
|
+
"""Names of the tables needed to decode the token, innermost first."""
|
|
96
|
+
return tuple(table_name(self.family, self.length, k) for k in range(1, self.iterations + 1))
|
|
97
|
+
|
|
98
|
+
def __str__(self) -> str:
|
|
99
|
+
return table_name(self.family, self.length, self.iterations)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Codec:
|
|
103
|
+
"""Encode and decode with the tables of one key store.
|
|
104
|
+
|
|
105
|
+
``keystore`` may be a :class:`~yace_codec.keystore.KeyStore`, a path to a
|
|
106
|
+
key store file, or ``None`` for the default key store
|
|
107
|
+
(:func:`~yace_codec.keystore.default_keystore_path`).
|
|
108
|
+
|
|
109
|
+
``rng`` replaces the operating system's random source for new tables and
|
|
110
|
+
for the per-token randomness. Pass a seeded :class:`random.Random` only to
|
|
111
|
+
get reproducible output in tests.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, keystore: KeyStoreLike = None, *, rng: Optional[Random] = None) -> None:
|
|
115
|
+
self.keystore = _as_keystore(keystore)
|
|
116
|
+
self._rng = rng
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return f"Codec({self.keystore!r})"
|
|
120
|
+
|
|
121
|
+
def encode(
|
|
122
|
+
self,
|
|
123
|
+
text: str,
|
|
124
|
+
name: str = DEFAULT_FAMILY,
|
|
125
|
+
*,
|
|
126
|
+
length: int = DEFAULT_LENGTH,
|
|
127
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Encode ``text`` with the table family ``name`` and return the token.
|
|
130
|
+
|
|
131
|
+
Missing tables ``name-length:1`` to ``name-length:iterations`` are
|
|
132
|
+
generated and saved to the key store; existing ones are reused.
|
|
133
|
+
|
|
134
|
+
Raises :class:`~yace_codec.errors.UnsupportedCharacterError` if ``text``
|
|
135
|
+
contains characters the table cannot encode (new tables cover
|
|
136
|
+
:data:`string.printable`; use :meth:`encode_bytes` for anything else),
|
|
137
|
+
:class:`~yace_codec.errors.InvalidNameError` for an invalid ``name``, and
|
|
138
|
+
:class:`~yace_codec.errors.EncodingError` for invalid settings.
|
|
139
|
+
"""
|
|
140
|
+
if not isinstance(text, str):
|
|
141
|
+
raise TypeError(
|
|
142
|
+
f"text must be str, not {type(text).__name__}; use encode_bytes() for bytes"
|
|
143
|
+
)
|
|
144
|
+
validate_family(name)
|
|
145
|
+
_check_settings(length, iterations)
|
|
146
|
+
if len(text) * length**iterations > MAX_ENCODED_CHARS:
|
|
147
|
+
raise EncodingError(
|
|
148
|
+
f"the token would exceed {MAX_ENCODED_CHARS} data characters "
|
|
149
|
+
f"({len(text)} characters x {length}**{iterations}); "
|
|
150
|
+
"lower the length or the number of iterations"
|
|
151
|
+
)
|
|
152
|
+
used = frozenset(text)
|
|
153
|
+
first = table_name(name, length, 1)
|
|
154
|
+
existing = self.keystore.find(first)
|
|
155
|
+
# Check before generating tables, so invalid input leaves the store unchanged.
|
|
156
|
+
_check_characters(used, existing.characters if existing else SUPPORTED_CHARACTERS, first)
|
|
157
|
+
tables = self.keystore.ensure_tables(name, length, iterations, rng=self._rng)
|
|
158
|
+
_check_characters(used, tables[0].characters, first)
|
|
159
|
+
last = tables[-1]
|
|
160
|
+
if last.bin_codes and not last.usable_bin_codes:
|
|
161
|
+
# Only possible with hand-made or legacy tables.
|
|
162
|
+
raise EncodingError(
|
|
163
|
+
f"every bin code of table {last.name!r} is also a character or space code, "
|
|
164
|
+
"so it cannot produce an unambiguous token"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
rng = self._rng or _SYSTEM_RANDOM
|
|
168
|
+
try:
|
|
169
|
+
data = text
|
|
170
|
+
for table in tables:
|
|
171
|
+
data = _substitute(data, table, rng)
|
|
172
|
+
noisy = _insert_bins(data.encode("ascii"), length, last.usable_bin_codes, rng)
|
|
173
|
+
body = _swap_chunks(noisy, length).decode("ascii")
|
|
174
|
+
except MemoryError:
|
|
175
|
+
raise EncodingError(
|
|
176
|
+
"not enough memory for this token; lower the length or the number of iterations"
|
|
177
|
+
) from None
|
|
178
|
+
return f"{table_name(name, length, iterations)}\n{body}"
|
|
179
|
+
|
|
180
|
+
def decode(self, token: TokenLike) -> str:
|
|
181
|
+
"""Decode a token produced by :meth:`encode` (or by the original scripts).
|
|
182
|
+
|
|
183
|
+
Whitespace inside the token is ignored, so wrapped or CRLF tokens work.
|
|
184
|
+
|
|
185
|
+
Raises :class:`~yace_codec.errors.TableNotFoundError` if the key store
|
|
186
|
+
lacks a required table and :class:`~yace_codec.errors.DecodingError` if
|
|
187
|
+
the token is malformed or was encoded with different tables.
|
|
188
|
+
"""
|
|
189
|
+
header, body = _split_token(token)
|
|
190
|
+
tables = [self.keystore.get(name) for name in header.table_names]
|
|
191
|
+
length = header.length
|
|
192
|
+
data = body
|
|
193
|
+
for layer in range(header.iterations, 0, -1):
|
|
194
|
+
if len(data) % length:
|
|
195
|
+
raise DecodingError(
|
|
196
|
+
f"layer {layer} has {len(data)} characters, which is not a multiple of "
|
|
197
|
+
f"the code length {length}; the token is truncated or corrupted"
|
|
198
|
+
)
|
|
199
|
+
outermost = layer == header.iterations
|
|
200
|
+
if outermost:
|
|
201
|
+
data = _swap_chunks(data.encode("ascii"), length).decode("ascii")
|
|
202
|
+
table = tables[layer - 1]
|
|
203
|
+
mapping = table.reverse_map(plaintext=layer == 1, outermost=outermost)
|
|
204
|
+
chunks = [data[i : i + length] for i in range(0, len(data), length)]
|
|
205
|
+
data = _restore(chunks, mapping, table, outermost=outermost)
|
|
206
|
+
return data
|
|
207
|
+
|
|
208
|
+
def encode_bytes(
|
|
209
|
+
self,
|
|
210
|
+
data: Union[bytes, bytearray, memoryview],
|
|
211
|
+
name: str = DEFAULT_FAMILY,
|
|
212
|
+
*,
|
|
213
|
+
length: int = DEFAULT_LENGTH,
|
|
214
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Encode arbitrary bytes (base64 first, then :meth:`encode`).
|
|
217
|
+
|
|
218
|
+
Use this for binary files and for text outside :data:`string.printable`,
|
|
219
|
+
e.g. ``codec.encode_bytes("café".encode("utf-8"))``. Decode the token
|
|
220
|
+
with :meth:`decode_bytes`.
|
|
221
|
+
"""
|
|
222
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
223
|
+
raise TypeError(f"data must be bytes-like, not {type(data).__name__}")
|
|
224
|
+
text = base64.b64encode(bytes(data)).decode("ascii")
|
|
225
|
+
return self.encode(text, name, length=length, iterations=iterations)
|
|
226
|
+
|
|
227
|
+
def decode_bytes(self, token: TokenLike) -> bytes:
|
|
228
|
+
"""Decode a token produced by :meth:`encode_bytes`."""
|
|
229
|
+
text = self.decode(token)
|
|
230
|
+
try:
|
|
231
|
+
return base64.b64decode(text.encode("ascii"), validate=True)
|
|
232
|
+
except (binascii.Error, UnicodeEncodeError) as exc:
|
|
233
|
+
raise DecodingError(
|
|
234
|
+
"the decoded text is not base64; was the token made with encode_bytes()?"
|
|
235
|
+
) from exc
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def encode(
|
|
239
|
+
text: str,
|
|
240
|
+
name: str = DEFAULT_FAMILY,
|
|
241
|
+
*,
|
|
242
|
+
length: int = DEFAULT_LENGTH,
|
|
243
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
244
|
+
keystore: KeyStoreLike = None,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""Encode ``text``; shortcut for ``Codec(keystore).encode(...)``."""
|
|
247
|
+
return Codec(keystore).encode(text, name, length=length, iterations=iterations)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def decode(token: TokenLike, *, keystore: KeyStoreLike = None) -> str:
|
|
251
|
+
"""Decode ``token``; shortcut for ``Codec(keystore).decode(token)``."""
|
|
252
|
+
return Codec(keystore).decode(token)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def encode_bytes(
|
|
256
|
+
data: Union[bytes, bytearray, memoryview],
|
|
257
|
+
name: str = DEFAULT_FAMILY,
|
|
258
|
+
*,
|
|
259
|
+
length: int = DEFAULT_LENGTH,
|
|
260
|
+
iterations: int = DEFAULT_ITERATIONS,
|
|
261
|
+
keystore: KeyStoreLike = None,
|
|
262
|
+
) -> str:
|
|
263
|
+
"""Encode bytes; shortcut for ``Codec(keystore).encode_bytes(...)``."""
|
|
264
|
+
return Codec(keystore).encode_bytes(data, name, length=length, iterations=iterations)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def decode_bytes(token: TokenLike, *, keystore: KeyStoreLike = None) -> bytes:
|
|
268
|
+
"""Decode a bytes token; shortcut for ``Codec(keystore).decode_bytes(token)``."""
|
|
269
|
+
return Codec(keystore).decode_bytes(token)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_header(token: TokenLike) -> Header:
|
|
273
|
+
"""Return the :class:`Header` of ``token`` without decoding it.
|
|
274
|
+
|
|
275
|
+
``parse_header(token).table_names`` lists the tables a key store needs to
|
|
276
|
+
decode the token.
|
|
277
|
+
"""
|
|
278
|
+
return _split_token(token)[0]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _as_keystore(keystore: KeyStoreLike) -> KeyStore:
|
|
282
|
+
if keystore is None:
|
|
283
|
+
return KeyStore.default()
|
|
284
|
+
if isinstance(keystore, KeyStore):
|
|
285
|
+
return keystore
|
|
286
|
+
if isinstance(keystore, (str, os.PathLike)):
|
|
287
|
+
return KeyStore(keystore)
|
|
288
|
+
raise TypeError(f"keystore must be a KeyStore, a path, or None, not {type(keystore).__name__}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _check_settings(length: object, iterations: object) -> None:
|
|
292
|
+
if not _is_int(length) or not MIN_LENGTH <= length <= MAX_LENGTH: # type: ignore[operator]
|
|
293
|
+
raise EncodingError(
|
|
294
|
+
f"length must be an integer from {MIN_LENGTH} to {MAX_LENGTH}, got {length!r}"
|
|
295
|
+
)
|
|
296
|
+
if not _is_int(iterations) or not 1 <= iterations <= MAX_ITERATIONS: # type: ignore[operator]
|
|
297
|
+
raise EncodingError(
|
|
298
|
+
f"iterations must be an integer from 1 to {MAX_ITERATIONS}, got {iterations!r}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _is_int(value: object) -> bool:
|
|
303
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _check_characters(used: Iterable[str], supported: Iterable[str], name: str) -> None:
|
|
307
|
+
unsupported = sorted(set(used).difference(supported))
|
|
308
|
+
if unsupported:
|
|
309
|
+
shown = ", ".join(repr(ch) for ch in unsupported[:10])
|
|
310
|
+
if len(unsupported) > 10:
|
|
311
|
+
shown += f" and {len(unsupported) - 10} more"
|
|
312
|
+
raise UnsupportedCharacterError(
|
|
313
|
+
f"table {name!r} cannot encode {shown}; use encode_bytes() for "
|
|
314
|
+
"arbitrary text or binary data",
|
|
315
|
+
tuple(unsupported),
|
|
316
|
+
name,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _substitute(text: str, table: Table, rng: Random) -> str:
|
|
321
|
+
if SPACE_KEY not in text:
|
|
322
|
+
return table.translate(text)
|
|
323
|
+
parts = text.split(SPACE_KEY)
|
|
324
|
+
spaces = rng.choices(table.usable_space_codes, k=len(parts) - 1)
|
|
325
|
+
spaces.append("")
|
|
326
|
+
return "".join([piece for part, space in zip(parts, spaces) for piece in (table.translate(part), space)])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _insert_bins(data: bytes, length: int, bins: Sequence[str], rng: Random) -> bytes:
|
|
330
|
+
"""Insert ``ceil(chunks / 4)`` random bin codes at random chunk positions."""
|
|
331
|
+
chunks = len(data) // length
|
|
332
|
+
count = -(-chunks // 4)
|
|
333
|
+
if not count or not bins:
|
|
334
|
+
return data
|
|
335
|
+
slots = sorted(rng.sample(range(chunks + count), count))
|
|
336
|
+
codes = rng.choices([code.encode("ascii") for code in bins], k=count)
|
|
337
|
+
# The i-th bin (in slot order) lands after slots[i] - i data chunks.
|
|
338
|
+
cuts = [(slot - i) * length for i, slot in enumerate(slots)]
|
|
339
|
+
segments = [data[start:cut] for start, cut in zip([0, *cuts], cuts)]
|
|
340
|
+
pieces = [piece for pair in zip(segments, codes) for piece in pair]
|
|
341
|
+
pieces.append(data[cuts[-1] :])
|
|
342
|
+
return b"".join(pieces)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _swap_chunks(data: bytes, length: int) -> bytes:
|
|
346
|
+
"""Swap chunk ``x - 1`` with chunk ``n - x`` for ``x = length, 2*length, ... < n``.
|
|
347
|
+
|
|
348
|
+
This is the original "misordering" step. The swapped pairs never overlap,
|
|
349
|
+
so the function is its own inverse.
|
|
350
|
+
|
|
351
|
+
Chunks ``x - 1`` sit every ``length`` chunks from chunk ``length - 1``, and
|
|
352
|
+
their partners ``n - x`` sit every ``length`` chunks up to chunk ``n - length``.
|
|
353
|
+
Strided slices move byte ``offset`` of all those chunks at once; reversing
|
|
354
|
+
one side pairs the first chunk of one group with the last of the other.
|
|
355
|
+
"""
|
|
356
|
+
n = len(data) // length
|
|
357
|
+
pairs = len(range(length, n, length))
|
|
358
|
+
if not pairs:
|
|
359
|
+
return bytes(data)
|
|
360
|
+
out = bytearray(data)
|
|
361
|
+
step = length * length
|
|
362
|
+
for offset in range(length):
|
|
363
|
+
first = (length - 1) * length + offset
|
|
364
|
+
last = (n - pairs * length) * length + offset
|
|
365
|
+
lower = slice(first, first + pairs * step, step)
|
|
366
|
+
upper = slice(last, last + pairs * step, step)
|
|
367
|
+
out[lower] = data[upper][::-1]
|
|
368
|
+
out[upper] = data[lower][::-1]
|
|
369
|
+
return bytes(out)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _restore(
|
|
373
|
+
chunks: List[str], mapping: Dict[str, Optional[str]], table: Table, *, outermost: bool
|
|
374
|
+
) -> str:
|
|
375
|
+
"""Map one layer's chunks back to characters and drop the bins.
|
|
376
|
+
|
|
377
|
+
On the outermost layer, the number of bins must match the token length
|
|
378
|
+
(see :func:`_bins_inserted`). Otherwise chunks were lost or added.
|
|
379
|
+
"""
|
|
380
|
+
if not outermost:
|
|
381
|
+
return "".join(_lookup(chunks, mapping, table))
|
|
382
|
+
if any(mapping.get(code) is not None for code in table.bin_codes):
|
|
383
|
+
decoded = _lookup(chunks, mapping, table)
|
|
384
|
+
_drop_shared_bins(decoded, chunks, mapping, table.bin_codes)
|
|
385
|
+
bins = decoded.count(None)
|
|
386
|
+
text = "".join([ch for ch in decoded if ch is not None])
|
|
387
|
+
else:
|
|
388
|
+
fast = {code: ("" if ch is None else ch) for code, ch in mapping.items()}
|
|
389
|
+
text = "".join(_lookup(chunks, fast, table))
|
|
390
|
+
bins = len(chunks) - len(text)
|
|
391
|
+
expected = _bins_inserted(len(chunks))
|
|
392
|
+
if table.bin_codes and bins != expected:
|
|
393
|
+
implied = "no valid count" if expected is None else str(expected)
|
|
394
|
+
raise DecodingError(
|
|
395
|
+
f"the token holds {bins} noise chunks, but its length implies {implied}; "
|
|
396
|
+
"it is truncated, extended, or corrupted"
|
|
397
|
+
)
|
|
398
|
+
return text
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _lookup(chunks: List[str], mapping: Dict[str, Any], table: Table) -> List[Any]:
|
|
402
|
+
try:
|
|
403
|
+
return list(map(mapping.__getitem__, chunks))
|
|
404
|
+
except KeyError:
|
|
405
|
+
index = next(i for i, chunk in enumerate(chunks) if chunk not in mapping)
|
|
406
|
+
raise DecodingError(
|
|
407
|
+
f"{chunks[index]!r} at offset {index * table.length} is not a valid code of "
|
|
408
|
+
f"table {table.name!r}; the token is corrupted or was encoded with different tables"
|
|
409
|
+
) from None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _bins_inserted(chunks: int) -> Optional[int]:
|
|
413
|
+
"""Return how many bins a token of ``chunks`` chunks holds, or ``None``.
|
|
414
|
+
|
|
415
|
+
The encoder adds ``ceil(m / 4)`` bins to ``m`` data chunks. That total grows
|
|
416
|
+
strictly with ``m``, so the chunk count determines the bin count.
|
|
417
|
+
"""
|
|
418
|
+
guess = 4 * chunks // 5
|
|
419
|
+
for m in (guess - 1, guess, guess + 1):
|
|
420
|
+
if m >= 0 and m - (-m // 4) == chunks:
|
|
421
|
+
return chunks - m
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _drop_shared_bins(
|
|
426
|
+
decoded: List[Optional[str]],
|
|
427
|
+
chunks: List[str],
|
|
428
|
+
mapping: Dict[str, Optional[str]],
|
|
429
|
+
bin_codes: Sequence[str],
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Resolve bins whose code a legacy table also uses for a character or space.
|
|
432
|
+
|
|
433
|
+
Such chunks first decode as the character or space. The token length tells
|
|
434
|
+
how many bins the encoder inserted (see :func:`_bins_inserted`), so if fewer
|
|
435
|
+
chunks decoded as bins, some shared-code chunks are bins. Like the original
|
|
436
|
+
decoder, drop the first occurrence of each shared code when that gives the
|
|
437
|
+
right count; otherwise drop the earliest shared-code chunks. Tables
|
|
438
|
+
generated by this package never share codes, so this is a no-op for them.
|
|
439
|
+
"""
|
|
440
|
+
shared = {code for code in bin_codes if mapping.get(code) is not None}
|
|
441
|
+
expected = _bins_inserted(len(decoded)) if shared else None
|
|
442
|
+
if expected is None:
|
|
443
|
+
return
|
|
444
|
+
missing = expected - decoded.count(None)
|
|
445
|
+
if missing <= 0:
|
|
446
|
+
return
|
|
447
|
+
positions = [i for i, chunk in enumerate(chunks) if chunk in shared]
|
|
448
|
+
first_of_each = {chunks[i]: i for i in reversed(positions)}
|
|
449
|
+
if len(first_of_each) == missing:
|
|
450
|
+
chosen = list(first_of_each.values())
|
|
451
|
+
elif missing <= len(positions):
|
|
452
|
+
chosen = positions[:missing]
|
|
453
|
+
else:
|
|
454
|
+
return
|
|
455
|
+
for i in chosen:
|
|
456
|
+
decoded[i] = None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _split_token(token: TokenLike) -> Tuple[Header, str]:
|
|
460
|
+
if isinstance(token, (bytes, bytearray)):
|
|
461
|
+
raw = bytes(token)
|
|
462
|
+
# Windows tools often add a byte order mark (and PowerShell 5.1 writes UTF-16).
|
|
463
|
+
encoding = next((enc for bom, enc in _BOMS if raw.startswith(bom)), "utf-8")
|
|
464
|
+
try:
|
|
465
|
+
token = raw.decode(encoding)
|
|
466
|
+
except UnicodeDecodeError:
|
|
467
|
+
raise DecodingError(
|
|
468
|
+
"a token must be text: UTF-8, or UTF-16/UTF-32 with a byte order mark"
|
|
469
|
+
) from None
|
|
470
|
+
elif not isinstance(token, str):
|
|
471
|
+
raise TypeError(f"token must be str or bytes, not {type(token).__name__}")
|
|
472
|
+
text = token[1:] if token.startswith("\ufeff") else token
|
|
473
|
+
stripped = text.strip()
|
|
474
|
+
if not stripped:
|
|
475
|
+
raise DecodingError("the token is empty")
|
|
476
|
+
# The header is the first line. The original scripts allowed spaces in family
|
|
477
|
+
# names, so try the whole line first, then "header body" on a single line.
|
|
478
|
+
first_line, _, body = stripped.partition("\n")
|
|
479
|
+
head = first_line.strip()
|
|
480
|
+
parsed = parse_table_name(head)
|
|
481
|
+
if parsed is None:
|
|
482
|
+
parts = stripped.split(None, 1)
|
|
483
|
+
head, body = parts[0], (parts[1] if len(parts) > 1 else "")
|
|
484
|
+
parsed = parse_table_name(head)
|
|
485
|
+
if parsed is None:
|
|
486
|
+
raise DecodingError(
|
|
487
|
+
f"invalid header {head[:40]!r}; expected '<name>-<length>:<iterations>'"
|
|
488
|
+
)
|
|
489
|
+
family, length, iterations = parsed
|
|
490
|
+
if not MIN_LENGTH <= length <= MAX_LENGTH or iterations < 1:
|
|
491
|
+
raise DecodingError(f"invalid header {head!r}: unsupported length or iteration count")
|
|
492
|
+
body = "".join(body.split())
|
|
493
|
+
if not _CODE_CHARS.issuperset(body):
|
|
494
|
+
bad = sorted(set(body) - _CODE_CHARS)[:5]
|
|
495
|
+
raise DecodingError(
|
|
496
|
+
f"the token contains characters the encoder never produces: {bad!r}"
|
|
497
|
+
)
|
|
498
|
+
return Header(family, length, iterations), body
|
yace_codec/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exceptions raised by yace_codec.
|
|
2
|
+
|
|
3
|
+
Every exception derives from :class:`YaceError`, so ``except YaceError`` catches
|
|
4
|
+
all of them. Errors about bad input also derive from :class:`ValueError`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DecodingError",
|
|
13
|
+
"EncodingError",
|
|
14
|
+
"InvalidNameError",
|
|
15
|
+
"KeyStoreError",
|
|
16
|
+
"TableNotFoundError",
|
|
17
|
+
"UnsupportedCharacterError",
|
|
18
|
+
"YaceError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class YaceError(Exception):
|
|
23
|
+
"""Base class for all yace_codec errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidNameError(YaceError, ValueError):
|
|
27
|
+
"""A table family name is not allowed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EncodingError(YaceError, ValueError):
|
|
31
|
+
"""The input cannot be encoded with the requested settings."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnsupportedCharacterError(EncodingError):
|
|
35
|
+
"""The text contains characters that the substitution table cannot encode.
|
|
36
|
+
|
|
37
|
+
``characters`` holds the offending characters and ``table_name`` names the
|
|
38
|
+
table that was checked.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, message: str, characters: Tuple[str, ...] = (), table_name: str = ""
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.characters = characters
|
|
46
|
+
self.table_name = table_name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DecodingError(YaceError, ValueError):
|
|
50
|
+
"""The token is malformed, corrupted, or does not match the key store's tables."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TableNotFoundError(YaceError, LookupError):
|
|
54
|
+
"""A table needed for decoding or export is not in the key store.
|
|
55
|
+
|
|
56
|
+
``table_name`` names the missing table (or family).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, table_name: str = "") -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.table_name = table_name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KeyStoreError(YaceError):
|
|
65
|
+
"""The key store cannot be read, parsed, validated, or written."""
|