Skip to content

Commit 88df782

Browse files
committed
Fixed some base encodings and performance issues
1 parent 0466f6f commit 88df782

5 files changed

Lines changed: 141 additions & 29 deletions

File tree

‎docs/pages/guessing.md‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ The following parameters are tunable:
1111
- `stop_func`: can be a function or a regular expression to be matched (automatically converted to a function that uses the `re` module) ; by default, checks if all input characters are printable.
1212
- `min_depth`: the minimum depth for the tree search (allows to avoid a bit of overhead while checking the current decoded output at a depth with the stop function when we are sure it should not be the right result) ; by default 0.
1313
- `max_depth`: the maximum depth for the tree search ; by default 5.
14-
- `codec_categories`: a string indicating a codec [category](features.md#list-codecs) or a list of [category](features.md#list-codecs) strings ; by default, `None`, meaning the whole [categories](features.md#list-codecs) (very slow).
14+
- `include`: the [categories](features.md#list-codecs), codecs or encodings to be considered, as a single name, a list of names or a dictionary of lists of names per depth (from 0, -1 meaning any depth) ; by default, `None`, meaning all of them (very slow).
15+
- `exclude`: the [categories](features.md#list-codecs), codecs or encodings not to be considered, in the same formats ; by default, `None`, meaning none of them.
1516
- `found`: a list or tuple of currently found encodings that can be used to save time if the first decoding steps are known ; by default, an empty tuple.
1617

18+
The result is a dictionary of the decoded strings by tuple of encodings, in order of application.
19+
1720
A simple example for a 1-stage base64-encoded string:
1821

1922
```python
@@ -25,7 +28,7 @@ An example of a 2-stages base64- then base62-encoded string:
2528

2629
```python
2730
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7")
28-
{('base62',): 'VGhpcyBpcyBhIHRlc3Q='}
31+
{('chaocipher',): 'NGH3Hb8tYyJWGEEseSOq28aTtS7'}
2932
```
3033

3134
In the second example, we can see that the given encoded string is not decoded as expected. This is the case because the (default) stop condition is too broad and stops if all the characters of the output are printable. If we have a prior knowledge on what we should expect, we can input a simple string or a regex:
@@ -43,37 +46,43 @@ In the second example, we can see that the given encoded string is not decoded a
4346
{('base62', 'base64'): 'This is a test'}
4447
```
4548

46-
In this example, the string "*test*" is converted to a function that uses this string as regular expression. Instead of a string, we can also pass a function. For this purpose, standard [stop functions](#available-stop-functions) are predefined. So, we can for instance use `stopfunc.lang_en` to stop when we find something that is English. Note that working this way gives lots of false positives if the text is very short like in the example case. That's why the `codec_categories` argument is used to only consider baseX codecs. This is also demonstrated in the next examples.
49+
In this example, the string "*test*" is converted to a function that uses this string as regular expression. Instead of a string, we can also pass a function. For this purpose, standard [stop functions](#available-stop-functions) are predefined. So, we can for instance use `stopfunc.lang_en` to stop when we find something that is English. Note that working this way gives lots of false positives if the text is very short like in the example case. That's why the `include` argument is used to only consider baseX codecs. This is also demonstrated in the next examples.
4750

4851
```python
4952
>>> codext.stopfunc._reload_lang("langdetect")
50-
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", codext.stopfunc.lang_en, codec_categories="base")
51-
('This is a test', ('base62', 'base64'))
53+
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", codext.stopfunc.lang_en, include="base")
54+
{('base62', 'base64'): 'This is a test'}
5255
```
5356

5457
If we know the first encoding, we can set this in the `found` parameter to save time:
5558

5659
```python
5760
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", "test", found=["base62"])
58-
('This is a test', ('base62', 'base64'))
61+
{('base62', 'base64'): 'This is a test'}
5962
```
6063

61-
If we are sure that only `base` (which is a valid [category](features.md#list-codecs)) encodings are used, we can restrict the tree search using the `codec_categories` parameter to save time:
64+
If we are sure that only `base` (which is a valid [category](features.md#list-codecs)) encodings are used, we can restrict the tree search using the `include` parameter to save time, or even give the encodings to be considered at each depth, while the `exclude` parameter removes some of them:
6265

6366
```python
64-
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", "test", codec_categories="base")
65-
('This is a test', ('base62', 'base64'))
67+
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", "test", include="base")
68+
{('base62', 'base64'): 'This is a test'}
69+
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", "test", include={0: ["base62"], 1: ["base64"]})
70+
{('base62', 'base64'): 'This is a test'}
71+
>>> codext.guess("CJG3Ix8bVcSRMLOqwDUg28aDsT7", "test", include="base", exclude="base62")
72+
{}
6673
```
6774

6875
Another example of 2-stages encoded string:
6976

7077
```python
7178
>>> codext.guess("LSAuLi4uIC4uIC4uLiAvIC4uIC4uLiAvIC4tIC8gLSAuIC4uLiAt", "test")
72-
('this is a test', ('base64', 'morse'))
73-
>>> codext.guess("LSAuLi4uIC4uIC4uLiAvIC4uIC4uLiAvIC4tIC8gLSAuIC4uLiAt", "test", codec_categories=["base", "language"])
74-
('this is a test', ('base64', 'morse'))
79+
{('base64', 'morse'): 'this is a test'}
80+
>>> codext.guess("LSAuLi4uIC4uIC4uLiAvIC4uIC4uLiAvIC4tIC8gLSAuIC4uLiAt", "test", include=["base", "language"])
81+
{('base64', 'morse'): 'this is a test'}
7582
```
7683

84+
With the [CLI tool](cli.md), these parameters are given with the `-i`/`--include` and `-e`/`--exclude` options, the names being possibly prefixed with a depth (e.g. `codext guess -i 0:base62 1:base64`).
85+
7786
When multiple results are expected, `stop` and `show` arguments can be used respectively to avoid stopping while finding a result and to display the intermediate result.
7887

7988
!!! warning "Computation time"

‎src/codext/base/_base.py‎

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
does).
1212
"""
1313
from argparse import ArgumentParser, RawTextHelpFormatter
14+
from functools import lru_cache
1415
from math import log
1516
from string import ascii_lowercase as lower, ascii_uppercase as upper, digits, printable
16-
from sys import stdout
17+
from sys import stderr, stdout
1718
from textwrap import wrap as wraptext
1819
from types import FunctionType, MethodType
1920

@@ -116,10 +117,57 @@ def _get_charset(charset, p=""):
116117
# or simply rely on key ''
117118
if default is not None:
118119
return default
119-
raise ValueError("Bad charset descriptor ('%s')" % p)
120+
raise ValueError(f"Bad charset descriptor ('{p}')")
120121

121122

122123
# generic base en/decoding functions
124+
# conversions between integers and digits in base N split the numbers with powers of N (divide and conquer), as repeated
125+
# divisions or multiplications by N take a quadratic time with large numbers ; below this number of digits, the
126+
# simple loops are faster
127+
_SMALL = 64
128+
129+
130+
@lru_cache(maxsize=128)
131+
def _power(n, k):
132+
return n ** k
133+
134+
135+
def _to_digits(i, n, width=0):
136+
""" Digits of the integer i in base N (as indices in the charset), left-padded with zeros to the given width (by
137+
default, the smallest power of 2 holding i, the leading zeros being then removed). """
138+
if (width or _SMALL + 1) <= _SMALL or not width and i < _power(n, _SMALL):
139+
d = []
140+
while i:
141+
i, c = divmod(i, n)
142+
d.append(c)
143+
return [0] * (width - len(d)) + d[::-1]
144+
top = not width
145+
if top:
146+
width = 2 * _SMALL
147+
while _power(n, width) <= i:
148+
width *= 2
149+
half = width // 2
150+
hi, lo = divmod(i, _power(n, half))
151+
d = _to_digits(hi, n, width - half) + _to_digits(lo, n, half)
152+
if top:
153+
k = 0
154+
while d[k] == 0:
155+
k += 1
156+
d = d[k:]
157+
return d
158+
159+
160+
def _from_digits(d, n):
161+
""" Integer of the digits in base N (as indices in the charset), combining halves as hi * N^len(lo) + lo. """
162+
if len(d) <= _SMALL:
163+
i = 0
164+
for c in d:
165+
i = i * n + c
166+
return i
167+
half = len(d) // 2
168+
return _from_digits(d[:half], n) * _power(n, len(d) - half) + _from_digits(d[half:], n)
169+
170+
123171
def base_encode(input, charset, errors="strict", exc=BaseEncodeError):
124172
""" Base-10 to base-N encoding.
125173
@@ -128,18 +176,16 @@ def base_encode(input, charset, errors="strict", exc=BaseEncodeError):
128176
:param errors: errors handling marker
129177
:param exc: exception to be raised in case of error
130178
"""
131-
i, n, r = input if isinstance(input, int) else s2i(input), len(charset), ""
179+
i, n = input if isinstance(input, int) else s2i(input) if input else 0, len(charset)
132180
if n == 1:
133181
if i > SIZE_LIMIT:
134182
raise InputSizeLimitError("Input exceeded size limit")
135183
return i * charset[0]
136184
# keep this fast-path only for a non-standard 10-character charset ; the digits
137-
# charset uses the generic loop below (leading zeros, bignums, no str/int round-trip)
185+
# charset uses the generic conversion below (leading zeros, bignums, no str/int round-trip)
138186
if n == 10 and charset != digits:
139187
return "".join(charset[int(x)] for x in str(i))
140-
while i > 0:
141-
i, c = divmod(i, n)
142-
r = charset[c] + r
188+
r = "".join(charset[c] for c in _to_digits(i, n)) if i > 0 else ""
143189
# preserve leading zero bytes: big-integer bases such as Base58 map each
144190
# leading null byte of the input to a leading charset[0] character
145191
if not isinstance(input, int):
@@ -155,18 +201,23 @@ def base_decode(input, charset, errors="strict", exc=BaseDecodeError):
155201
:param errors: errors handling marker
156202
:param exc: exception to be raised in case of error
157203
"""
158-
i, n, dec = 0, len(charset), lambda n: base_encode(n, [chr(x) for x in range(256)], errors, exc)
204+
n = len(charset)
159205
if n == 1:
160206
return i2s(len(input))
161207
if n == 10 and charset != digits:
162208
return "".join(str(charset.index(c)) for c in input)
209+
# index of each character (the first one, if the charset holds a character more than once)
210+
index, d = {c: k for k, c in reversed(list(enumerate(charset)))}, []
163211
for k, c in enumerate(input):
164212
try:
165-
i = i * n + charset.index(c)
166-
except ValueError:
167-
handle_error("base", errors, exc, decode=True)(c, k, dec(i), "base%d" % n)
213+
d.append(index[c])
214+
except KeyError:
215+
# no output decoded up to the error, as the digits before it are not the beginning of the number
216+
handle_error("base", errors, exc, decode=True)(c, k, "", f"base{n}")
217+
i = _from_digits(d, n)
168218
# restore the leading zero bytes encoded as leading charset[0] characters
169-
return chr(0) * (len(input) - len(input.lstrip(charset[0]))) + dec(i)
219+
return chr(0) * (len(input) - len(input.lstrip(charset[0]))) + i.to_bytes((i.bit_length() + 7) // 8,
220+
"big").decode("latin-1")
170221

171222

172223
# base codec factory functions
@@ -231,7 +282,7 @@ def _decode(input, errors="strict"):
231282
return _decode
232283

233284
add("base", encode, decode, r"^base[-_]?([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(?:[-_]generic)?$",
234-
guess=["base%d-generic" % i for i in range(2, 255)], entropy=lambda e, n: log(int(n.split("-")[0][4:]), 2),
285+
guess=[f"base{i}-generic" for i in range(2, 255)], entropy=lambda e, n: log(int(n.split("-")[0][4:]), 2),
235286
len_charset=lambda n: int(n.split("-")[0][4:]), printables_rate=1., category="base-generic", penalty=.4,
236287
expansion_factor=lambda f, n: (EXPANSION_FACTOR(int(n.split("-")[0][4:])), .05))
237288

@@ -294,7 +345,10 @@ def _main():
294345
c = f(c, "base" + base + ["", "-inv"][getattr(args, "invert", False)],
295346
["strict", "ignore"][args.ignore_garbage])
296347
except Exception as err:
297-
print("%sbase%s: invalid input" % (getattr(err, "output", ""), base))
348+
# as GNU's base64, write the output decoded up to the error (if any), then report the error on stderr
349+
stdout.buffer.write(b(getattr(err, "output", "") or ""))
350+
stdout.flush()
351+
print(f"base{base}: invalid input", file=stderr)
298352
return 1
299353
if args.decode:
300354
stdout.buffer.write(c)

‎src/codext/base/_base2n.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def base2n_decode(string, charset, errors="strict"):
6969
:param charset: base-N characters set
7070
:param errors: errors handling marker
7171
"""
72-
bs, r, n = "", "", len(charset)
72+
bs, r, n, nbits = "", "", len(charset), 0
7373
# particular case: for hex, ensure the right case in the charset ; not that this way, if mixed cases are used, it
7474
# will trigger an error (this is the expected behavior)
7575
if n == 16:
@@ -81,16 +81,24 @@ def base2n_decode(string, charset, errors="strict"):
8181
# find the number of bits for the given character set and the number of padding characters
8282
nb_in = int(log(n, 2))
8383
n_pad = len(string) - len(string.rstrip("="))
84+
def partial(pos):
85+
""" Output decoded up to an error at the given position, as with GNU's base64 and base32: the whole bytes of the
86+
bits of the characters before the error (excluding the padding characters), except that, with base32, a
87+
group of 8 characters cut by the end of the input gives no output (as with gnulib's decode_8). """
88+
out = (r + "".join(chr(int(bs[k:k+8], 2)) for k in range(0, len(bs) // 8 * 8, 8)))[:nbits // 8]
89+
return out[:pos // 8 * 5] if n == 32 and len(string) - pos // 8 * 8 < 8 else out
8490
# iterate over the characters, mapping them to the character set and converting the resulting bits to 8-bits chars
8591
for i, c in enumerate(string):
8692
if c == "=":
8793
bs += "0" * nb_in
8894
else:
8995
try:
9096
bs += ("{:0>%d}" % nb_in).format(_bin(charset.index(c))[2:])
97+
nbits += nb_in
9198
except ValueError:
9299
if errors == "strict":
93100
e = Base2NDecodeError("'base%d' codec can't decode character '%s' in position %d" % (n, c, i))
101+
e.output = partial(i)
94102
e.__cause__ = e # block exceptions chaining
95103
raise e
96104
elif errors == "replace":
@@ -105,7 +113,9 @@ def base2n_decode(string, charset, errors="strict"):
105113
# if the number of bits is not multiple of 8 bits, it could mean a bad padding
106114
if len(bs) != 8:
107115
if errors == "strict":
108-
raise Base2NDecodeError("Incorrect padding")
116+
e = Base2NDecodeError("Incorrect padding")
117+
e.output = partial(len(string))
118+
raise e
109119
elif errors in ["replace", "ignore"]:
110120
pass
111121
else:

‎src/codext/base/base45.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ def decode(text, errors="strict"):
8989
return decode
9090

9191

92-
add("base45", base45_encode, base45_decode, r"^base[-_]?45(|[-_]inv(?:erted)?)$", expansion_factor=1.5)
92+
# the expansion factor is 1.5 for pairs of bytes, up to 2 for a single byte (hence the tolerance for short inputs),
93+
# and the charset has 45 characters
94+
add("base45", base45_encode, base45_decode, r"^base[-_]?45(|[-_]inv(?:erted)?)$", expansion_factor=(1.5, .17),
95+
len_charset=45)
9396
main = main(45, "<https://datatracker.ietf.org/doc/html/draft-faltstrom-base45-04.txt>")
9497

‎tests/test_base.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Base codecs tests.
44
55
"""
6+
import codext
67
import sys
78
from unittest import TestCase
89

@@ -288,6 +289,10 @@ def test_codec_base45(self):
288289
self.assertEqual(codecs.decode(b"OBQR0", "base45"), b"\xcf\xb1\x1b")
289290
for data in [b"\xff\xfe", b"hello", b"\x00", b"\x80\x81\x82\x83\x84"]:
290291
self.assertEqual(codecs.decode(codecs.encode(data, "base45"), "base45"), data)
292+
# the ranking heuristic puts base45 first, also for a single byte (whose expansion factor is 2 instead of 1.5)
293+
for s in ["test", "The quick brown fox jumps over the lazy dog"]:
294+
self.assertTrue(codext.rank(codecs.encode(s, "base45"), limit=1)[0][1].startswith("base45"))
295+
self.assertTrue(any(n.startswith("base45") for _, n in codext.rank(codecs.encode("a", "base45"), limit=50)))
291296

292297
def test_codec_base_generic(self):
293298
for n in range(2, 255):
@@ -296,6 +301,17 @@ def test_codec_base_generic(self):
296301
self.assertRaises(LookupError, codecs.decode, "test", "base0-generic")
297302
self.assertRaises(LookupError, codecs.decode, "test", "base1-generic")
298303
self.assertRaises(LookupError, codecs.decode, "test", "base256-generic")
304+
# empty inputs, a known vector, integers and large inputs (converted by splitting with powers of the base)
305+
self.assertEqual(codecs.encode("", "base58-generic"), "")
306+
self.assertEqual(codecs.encode("Hello World!", "base58"), "2NEpo7TZRRrLZSi2U")
307+
from codext.base._base import base_decode, base_encode
308+
cs = _generate_charset(58)
309+
for i in [0, 57, 58, 58 ** 200 - 1, 58 ** 200]:
310+
self.assertEqual(base_decode(base_encode(i, cs), cs), i2s(i) if i else "")
311+
data = "\x00\x00" + "".join(chr(x % 256) for x in range(20000))
312+
for n in [3, 58, 200]:
313+
bn = f"base{n}-generic"
314+
self.assertEqual(codecs.decode(codecs.encode(data, bn), bn), data)
299315

300316
def test_codec_base1024(self):
301317
bad = codecs.encode("Hello!", "ecoji-v1") + "\U0001f170\u23f0\U0001f170\U0001f170"
@@ -307,6 +323,26 @@ def test_codec_base1024(self):
307323
bad = codecs.encode("Hello!", "ecoji").replace("\u2615", "!\u2615")
308324
self.assertEqual(codecs.decode(bad, "ecoji", "ignore"), "Hello!")
309325

326+
def test_base_decoding_partial_output(self):
327+
import subprocess
328+
# as GNU's base64 and base32, the output decoded up to an error is given (with the exception), base32 giving no
329+
# output for a group of 8 characters cut by the end of the input, and big-integer bases giving none at all
330+
for s, enc, out in [("SGVsbG8gV29ybGQ$IQ==", "base64", "Hello World"),
331+
("SGVsbG8gV29ybGQh$", "base64", "Hello World!"), ("SGVsbG8=$$$$", "base64", "Hello"),
332+
("SGVsbG8", "base64", "Hello"), ("S$GVs", "base64", ""), ("JBSWY3D$", "base32", "Hell"),
333+
("JBSWY3DPEB$", "base32", "Hello"), ("JBSWY3D", "base32", ""),
334+
("JBSWY3DPEBLW6$4TM", "base32", "Hello Wo"), ("2NEpo7TZ0RRrLZSi2U", "base58", "")]:
335+
with self.assertRaises(ValueError) as cm:
336+
codecs.decode(s, enc)
337+
self.assertEqual(getattr(cm.exception, "output", ""), out)
338+
# the CLI writes this output to stdout and the error to stderr
339+
src = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
340+
env = {**os.environ, 'PYTHONPATH': src + os.pathsep + os.environ.get('PYTHONPATH', "")}
341+
cmd = [sys.executable, "-c", "import sys ; from codext.base.baseN import main64 ; "
342+
"sys.argv = ['base64', '-d'] ; sys.exit(main64())"]
343+
r = subprocess.run(cmd, input=b"SGVsbG8gV29ybGQ$IQ==", capture_output=True, env=env)
344+
self.assertEqual((r.stdout, r.stderr.strip(), r.returncode), (b"Hello World", b"base64: invalid input", 1))
345+
310346
def test_base_main(self):
311347
tmp = sys.argv[:]
312348
tfile = "test-base-main.txt"

0 commit comments

Comments
 (0)