nix-base32 0.4.2__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.
- base32/__init__.py +47 -0
- base32/cli/__init__.py +32 -0
- base32/cli/__main__.py +9 -0
- base32/cli/io.py +26 -0
- base32/cli/ops.py +100 -0
- base32/decode.py +148 -0
- base32/detail/__init__.py +32 -0
- base32/detail/lengths.py +58 -0
- base32/detail/reverse_lookup.py +71 -0
- base32/detail/types.py +54 -0
- base32/encode.py +136 -0
- nix_base32-0.4.2.dist-info/METADATA +94 -0
- nix_base32-0.4.2.dist-info/RECORD +16 -0
- nix_base32-0.4.2.dist-info/WHEEL +4 -0
- nix_base32-0.4.2.dist-info/entry_points.txt +2 -0
- nix_base32-0.4.2.dist-info/licenses/LICENSE +21 -0
base32/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Pure-Python implementation of the Nix-style base32 codec.
|
|
2
|
+
|
|
3
|
+
The interface is two functions—:func:`encode` and :func:`decode`.
|
|
4
|
+
The codec generally follows upstream implementation (in Nix source tree)
|
|
5
|
+
|
|
6
|
+
The Nix base32 variant differs from RFC 4648 base32:
|
|
7
|
+
letters *e*, *o*, *u*, and *t* are omitted.
|
|
8
|
+
|
|
9
|
+
Example
|
|
10
|
+
-------
|
|
11
|
+
|
|
12
|
+
>>> from base32 import encode, decode
|
|
13
|
+
>>> s = encode(b"hello")
|
|
14
|
+
>>> s
|
|
15
|
+
NixBase32Str('nbswy3dp')
|
|
16
|
+
>>> decode(s)
|
|
17
|
+
b'hello'
|
|
18
|
+
|
|
19
|
+
The :class:`~base32.detail.types.NixBase32Str` type is returned to
|
|
20
|
+
ensure all encoded representations are valid according to Nix alphabet.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("nix-base32")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
__version__: Final[str] = "dev"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from .decode import decode, decode_iter, decode_stream, decode_to_stream
|
|
34
|
+
from .detail import NixBase32Str
|
|
35
|
+
from .encode import encode, encode_iter, encode_stream, encode_to_stream
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"NixBase32Str",
|
|
39
|
+
"decode",
|
|
40
|
+
"decode_iter",
|
|
41
|
+
"decode_stream",
|
|
42
|
+
"decode_to_stream",
|
|
43
|
+
"encode",
|
|
44
|
+
"encode_iter",
|
|
45
|
+
"encode_stream",
|
|
46
|
+
"encode_to_stream",
|
|
47
|
+
]
|
base32/cli/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""CLI interface."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .. import __version__
|
|
6
|
+
from . import ops
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True)
|
|
10
|
+
@click.version_option(__version__, "--version", "-v")
|
|
11
|
+
@click.option("-d", "decode", is_flag=True, help="Decode input.")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--sri",
|
|
14
|
+
is_flag=True,
|
|
15
|
+
help="Decode input, producing output in SRI format if the digest matches a known hash length.",
|
|
16
|
+
)
|
|
17
|
+
@click.option(
|
|
18
|
+
"-i", "--input", is_flag=False, default="-", help='input file (default: "-" for stdin)'
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"-o", "--output", is_flag=False, default="-", help='output file (default: "-" for stdout)'
|
|
22
|
+
)
|
|
23
|
+
def base32(input: str, output: str, decode: bool, sri: bool) -> None:
|
|
24
|
+
"""
|
|
25
|
+
The base32 utility acts as a Nix base32 variant decoder when passed the --decode (or -d) flag and as
|
|
26
|
+
a Nix base32 encoder otherwise.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
if decode or sri:
|
|
30
|
+
ops.decode(input, output, sri)
|
|
31
|
+
else:
|
|
32
|
+
ops.encode(input, output)
|
base32/cli/__main__.py
ADDED
base32/cli/io.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import TYPE_CHECKING, TextIO, cast
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@contextmanager
|
|
10
|
+
def infile(name: str) -> Iterator[TextIO]:
|
|
11
|
+
"""Yield a readable text stream for a filename or stdin if '-'."""
|
|
12
|
+
if name == "-":
|
|
13
|
+
yield cast("TextIO", sys.stdin)
|
|
14
|
+
else:
|
|
15
|
+
with open(name, encoding="utf-8") as f:
|
|
16
|
+
yield f
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def outfile(name: str) -> Iterator[TextIO]:
|
|
21
|
+
"""Yield a writable text stream for a filename or stdout if '-'."""
|
|
22
|
+
if name == "-":
|
|
23
|
+
yield cast("TextIO", sys.stdout)
|
|
24
|
+
else:
|
|
25
|
+
with open(name, "w", encoding="utf-8") as f:
|
|
26
|
+
yield f
|
base32/cli/ops.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
import base32
|
|
9
|
+
|
|
10
|
+
from .io import infile, outfile
|
|
11
|
+
|
|
12
|
+
# Expected hash lengths for SRI validation
|
|
13
|
+
SRI_HASH_LENGTHS: dict[str, int] = {
|
|
14
|
+
"sha256": 32,
|
|
15
|
+
"sha384": 48,
|
|
16
|
+
"sha512": 64,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode(input: str, output: str) -> None:
|
|
21
|
+
"""Reads input and writes Nix base32 output
|
|
22
|
+
|
|
23
|
+
:param input: input filename or '-' for stdin
|
|
24
|
+
:type input: str
|
|
25
|
+
:param output: output filename or '-' for stdout
|
|
26
|
+
:type output: str
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
data: str
|
|
30
|
+
|
|
31
|
+
with infile(input) as f:
|
|
32
|
+
data = f.read().strip()
|
|
33
|
+
|
|
34
|
+
# if data is SRI hash (e.g., sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=)
|
|
35
|
+
if m := re.match(
|
|
36
|
+
r"^(sha256|sha384|sha512)-((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$",
|
|
37
|
+
data,
|
|
38
|
+
):
|
|
39
|
+
hash_type = m.group(1)
|
|
40
|
+
payload = base64.b64decode(m.group(2))
|
|
41
|
+
|
|
42
|
+
# Validate hash length matches algorithm
|
|
43
|
+
expected_len = SRI_HASH_LENGTHS[hash_type]
|
|
44
|
+
if len(payload) != expected_len:
|
|
45
|
+
click.echo(
|
|
46
|
+
f"error: {hash_type} hash must be {expected_len} bytes, got {len(payload)}",
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
else:
|
|
51
|
+
if not re.fullmatch(r"[0-9a-fA-F]+", data):
|
|
52
|
+
click.echo("error: only hex or SRI input supported", err=True)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
payload = binascii.unhexlify(data)
|
|
55
|
+
|
|
56
|
+
with outfile(output) as f:
|
|
57
|
+
f.write(base32.encode(payload))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode(input: str, output: str, sri: bool) -> None:
|
|
61
|
+
"""Reads Nix base32 input and writes decoded output
|
|
62
|
+
|
|
63
|
+
:param input: input filename or '-' for stdin
|
|
64
|
+
:type input: str
|
|
65
|
+
:param output: output filename or '-' for stdout
|
|
66
|
+
:type output: str
|
|
67
|
+
:param sri: if set
|
|
68
|
+
:type sri: bool
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
data: str
|
|
72
|
+
|
|
73
|
+
with infile(input) as f:
|
|
74
|
+
data = f.read().strip()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
decoded = base32.decode(data)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
click.echo(f"decode error: {exc}", err=True)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
hexed = binascii.hexlify(decoded).decode("utf-8")
|
|
83
|
+
|
|
84
|
+
with outfile(output) as f:
|
|
85
|
+
if sri:
|
|
86
|
+
length = len(hexed)
|
|
87
|
+
prefix = {
|
|
88
|
+
64: "sha256",
|
|
89
|
+
96: "sha384",
|
|
90
|
+
128: "sha512",
|
|
91
|
+
}.get(length)
|
|
92
|
+
|
|
93
|
+
if prefix is None:
|
|
94
|
+
click.echo("SRI mode supported only for SHA256/384/512 digests", err=True)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
b64 = base64.b64encode(decoded).decode("utf-8")
|
|
98
|
+
f.write(f"{prefix}-{b64}")
|
|
99
|
+
else:
|
|
100
|
+
f.write(hexed)
|
base32/decode.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""base32 decoding utility following the Nix variant.
|
|
2
|
+
|
|
3
|
+
Implements :func:`decode`, the inverse of
|
|
4
|
+
:func:`base32.encode`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, BinaryIO, TextIO
|
|
8
|
+
|
|
9
|
+
from .detail import NixBase32Str, max_decoded_length, reverse_lookup
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode(s: str | NixBase32Str) -> bytes:
|
|
16
|
+
"""Decode a Nix base32 string back into the original bytes.
|
|
17
|
+
|
|
18
|
+
The algorithm reverses :func:`base32.encode`, consuming bits
|
|
19
|
+
five at a time from LSB to MSB (right to left).
|
|
20
|
+
|
|
21
|
+
:param s: Nix base32 string to decode.
|
|
22
|
+
:type s: str | base32.detail.types.NixBase32Str
|
|
23
|
+
:returns: Original binary data represented by ``s``.
|
|
24
|
+
:rtype: bytes
|
|
25
|
+
:raises ValueError: If the string contains invalid base32 symbol(s).
|
|
26
|
+
|
|
27
|
+
:example:
|
|
28
|
+
>>> from base32 import encode, decode
|
|
29
|
+
>>> s = encode(b"hi")
|
|
30
|
+
>>> s
|
|
31
|
+
NixBase32Str('nbqwcid')
|
|
32
|
+
>>> decode(s)
|
|
33
|
+
b'hi'
|
|
34
|
+
"""
|
|
35
|
+
if not s:
|
|
36
|
+
return b""
|
|
37
|
+
|
|
38
|
+
# Upper-bound capacity = ceil(len(s) * 5 / 8)
|
|
39
|
+
cap = max_decoded_length(len(s))
|
|
40
|
+
out = bytearray(cap)
|
|
41
|
+
used = 0
|
|
42
|
+
|
|
43
|
+
for n, ch in enumerate(reversed(s)):
|
|
44
|
+
digit = reverse_lookup(ch)
|
|
45
|
+
if digit is None:
|
|
46
|
+
raise ValueError(f"invalid character {ch!r}")
|
|
47
|
+
|
|
48
|
+
b = n * 5
|
|
49
|
+
i = b // 8
|
|
50
|
+
j = b % 8
|
|
51
|
+
|
|
52
|
+
out[i] = (out[i] | ((digit << j) & 0xFF)) & 0xFF
|
|
53
|
+
if used < i + 1:
|
|
54
|
+
used = i + 1
|
|
55
|
+
|
|
56
|
+
# If 5-bit group crosses byte boundary, spill over.
|
|
57
|
+
if j and (digit >> (8 - j)):
|
|
58
|
+
out[i + 1] = (out[i + 1] | (digit >> (8 - j))) & 0xFF
|
|
59
|
+
if used < i + 2:
|
|
60
|
+
used = i + 2
|
|
61
|
+
|
|
62
|
+
return bytes(out[:used])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def decode_iter(s: str | NixBase32Str, chunk_size: int = 8192) -> Iterator[bytes]:
|
|
66
|
+
"""Decode a Nix base32 string and yield output in chunks.
|
|
67
|
+
|
|
68
|
+
This is useful for memory-efficient writing of large decoded data.
|
|
69
|
+
The decoding itself requires the full input, but output is yielded
|
|
70
|
+
in manageable chunks.
|
|
71
|
+
|
|
72
|
+
:param s: Nix base32 string to decode.
|
|
73
|
+
:param chunk_size: Maximum bytes per yielded chunk.
|
|
74
|
+
:yields: Bytes chunks of the decoded output.
|
|
75
|
+
:raises ValueError: If the string contains invalid base32 symbol(s).
|
|
76
|
+
|
|
77
|
+
:example:
|
|
78
|
+
>>> for chunk in decode_iter("0" * 1000, chunk_size=100):
|
|
79
|
+
... print(len(chunk)) # Each chunk <= 100 bytes
|
|
80
|
+
"""
|
|
81
|
+
decoded = decode(s)
|
|
82
|
+
for i in range(0, len(decoded), chunk_size):
|
|
83
|
+
yield decoded[i : i + chunk_size]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def decode_to_stream(
|
|
87
|
+
s: str | NixBase32Str,
|
|
88
|
+
writer: BinaryIO,
|
|
89
|
+
*,
|
|
90
|
+
chunk_size: int = 8192,
|
|
91
|
+
) -> int:
|
|
92
|
+
"""Decode a Nix base32 string and write to a binary stream.
|
|
93
|
+
|
|
94
|
+
:param s: Nix base32 string to decode.
|
|
95
|
+
:param writer: Binary stream to write decoded output to.
|
|
96
|
+
:param chunk_size: Write buffer size in bytes.
|
|
97
|
+
:returns: Number of bytes written.
|
|
98
|
+
:raises ValueError: If the string contains invalid base32 symbol(s).
|
|
99
|
+
|
|
100
|
+
:example:
|
|
101
|
+
>>> import io
|
|
102
|
+
>>> buf = io.BytesIO()
|
|
103
|
+
>>> decode_to_stream("nbswy3dp", buf)
|
|
104
|
+
5
|
|
105
|
+
>>> buf.getvalue()
|
|
106
|
+
b'hello'
|
|
107
|
+
"""
|
|
108
|
+
written = 0
|
|
109
|
+
for chunk in decode_iter(s, chunk_size):
|
|
110
|
+
writer.write(chunk)
|
|
111
|
+
written += len(chunk)
|
|
112
|
+
return written
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def decode_stream(
|
|
116
|
+
reader: TextIO,
|
|
117
|
+
writer: BinaryIO,
|
|
118
|
+
*,
|
|
119
|
+
chunk_size: int = 8192,
|
|
120
|
+
strip: bool = True,
|
|
121
|
+
) -> int:
|
|
122
|
+
"""Read base32 text from a stream, decode, and write to binary stream.
|
|
123
|
+
|
|
124
|
+
.. note::
|
|
125
|
+
Due to the Nix base32 algorithm processing characters in reverse
|
|
126
|
+
order, the full input must be buffered before decoding.
|
|
127
|
+
This function provides streaming I/O but not streaming computation.
|
|
128
|
+
|
|
129
|
+
:param reader: Text stream to read base32 input from.
|
|
130
|
+
:param writer: Binary stream to write decoded output to.
|
|
131
|
+
:param chunk_size: I/O buffer size.
|
|
132
|
+
:param strip: Whether to strip whitespace from input (default True).
|
|
133
|
+
:returns: Number of bytes written.
|
|
134
|
+
:raises ValueError: If the input contains invalid base32 symbol(s).
|
|
135
|
+
|
|
136
|
+
:example:
|
|
137
|
+
>>> import io
|
|
138
|
+
>>> inp = io.StringIO("nbswy3dp")
|
|
139
|
+
>>> out = io.BytesIO()
|
|
140
|
+
>>> decode_stream(inp, out)
|
|
141
|
+
5
|
|
142
|
+
>>> out.getvalue()
|
|
143
|
+
b'hello'
|
|
144
|
+
"""
|
|
145
|
+
data = reader.read()
|
|
146
|
+
if strip:
|
|
147
|
+
data = data.strip()
|
|
148
|
+
return decode_to_stream(data, writer, chunk_size=chunk_size)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Low-level internal utilities for Nix-style base32 encoding and decoding.
|
|
2
|
+
|
|
3
|
+
This subpackage provides fine-grained tools used by higher-level base32
|
|
4
|
+
logic. It encapsulates Nix-specific alphabet definitions, validation
|
|
5
|
+
types, length computations, and fast reverse-lookup functionality.
|
|
6
|
+
|
|
7
|
+
The exposed API is intentionally narrow—only stable primitives required
|
|
8
|
+
by the public encoder/decoder surface are exported.
|
|
9
|
+
|
|
10
|
+
Example usage::
|
|
11
|
+
|
|
12
|
+
from base32.detail import encoded_length, reverse_lookup
|
|
13
|
+
|
|
14
|
+
n = 16
|
|
15
|
+
print(f"{n} bytes -> roughly {encoded_length(n)} base32 chars")
|
|
16
|
+
|
|
17
|
+
digit = reverse_lookup("f")
|
|
18
|
+
print(f"Digit for 'f': {digit}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .lengths import encoded_length, max_decoded_length
|
|
22
|
+
from .reverse_lookup import reverse_lookup
|
|
23
|
+
from .types import INVALID, NixBase32Str, charset
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"INVALID",
|
|
27
|
+
"NixBase32Str",
|
|
28
|
+
"charset",
|
|
29
|
+
"encoded_length",
|
|
30
|
+
"max_decoded_length",
|
|
31
|
+
"reverse_lookup",
|
|
32
|
+
]
|
base32/detail/lengths.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Length computation helpers for Nix base32 encoding.
|
|
2
|
+
|
|
3
|
+
These functions mirror the capacity and sizing formulas used by the
|
|
4
|
+
official Nix implementation. They allow callers to estimate the output
|
|
5
|
+
length of encoded data or the maximum number of bytes that can be safely
|
|
6
|
+
decoded from a given base32 representation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def encoded_length(n: int) -> int:
|
|
11
|
+
"""Compute the number of Nix base32 characters required to encode ``n`` bytes.
|
|
12
|
+
|
|
13
|
+
The calculation uses the formula::
|
|
14
|
+
|
|
15
|
+
ceil((n * 8) / 5)
|
|
16
|
+
|
|
17
|
+
expressed in integer arithmetic as ``(n * 8 - 1) // 5 + 1``.
|
|
18
|
+
It ensures sufficient capacity even when input length isn't a multiple
|
|
19
|
+
of 5-bits.
|
|
20
|
+
|
|
21
|
+
:param n: Number of bytes in the input data.
|
|
22
|
+
:type n: int
|
|
23
|
+
:returns: Number of base32 characters required to represent the input.
|
|
24
|
+
:rtype: int
|
|
25
|
+
|
|
26
|
+
:example:
|
|
27
|
+
>>> encoded_length(1)
|
|
28
|
+
2
|
|
29
|
+
>>> encoded_length(5)
|
|
30
|
+
8
|
|
31
|
+
"""
|
|
32
|
+
return (n * 8 - 1) // 5 + 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def max_decoded_length(n: int) -> int:
|
|
36
|
+
"""Compute the maximum number of bytes that may decode from ``n`` base32 characters.
|
|
37
|
+
|
|
38
|
+
This mirrors the logic used in the Nix reference decoder:
|
|
39
|
+
``ceil(n * 5 / 8)``.
|
|
40
|
+
It yields the upper bound of decoded bytes to aid buffer preallocation.
|
|
41
|
+
|
|
42
|
+
:param n: Number of base32 characters in the encoded input.
|
|
43
|
+
:type n: int
|
|
44
|
+
:returns: Maximum number of bytes that can be decoded.
|
|
45
|
+
:rtype: int
|
|
46
|
+
|
|
47
|
+
:example:
|
|
48
|
+
>>> max_decoded_length(2)
|
|
49
|
+
1
|
|
50
|
+
>>> max_decoded_length(8)
|
|
51
|
+
5
|
|
52
|
+
|
|
53
|
+
.. seealso::
|
|
54
|
+
**Nix reference implementation:**
|
|
55
|
+
https://github.com/NixOS/nix/blob/fb117e0cacc9b0bb29288ee9d3cb6dc0b5ff34a5/src/libutil/base-nix-32.cc#L45
|
|
56
|
+
"""
|
|
57
|
+
# ceil(n * 5 / 8): capacity upper-bound used by the reference decoder
|
|
58
|
+
return (n * 5 + 7) // 8
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Reverse lookup utilities for the Nix-style base32 alphabet.
|
|
2
|
+
|
|
3
|
+
Provides a table-based constant-time mapping from base32 characters back
|
|
4
|
+
to their numeric digit values.
|
|
5
|
+
Invalid characters are consistently mapped to the :data:`INVALID`
|
|
6
|
+
sentinel value, enabling efficient checks during decoding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .types import INVALID, charset
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_lookup_table() -> list[int]:
|
|
13
|
+
"""Construct the ASCII lookup table for the Nix base32 alphabet.
|
|
14
|
+
|
|
15
|
+
Builds a 256-element array mapping every possible 8-bit character
|
|
16
|
+
to its corresponding base32 digit value or :data:`INVALID` if the
|
|
17
|
+
character is not part of the alphabet.
|
|
18
|
+
|
|
19
|
+
:returns:
|
|
20
|
+
Table of integer values indexed by character ordinal
|
|
21
|
+
(0..255).
|
|
22
|
+
:rtype: list[int]
|
|
23
|
+
|
|
24
|
+
.. important::
|
|
25
|
+
Called automatically at module import to initialize
|
|
26
|
+
:data:`lookup_table`. In normal use this function need not
|
|
27
|
+
be invoked manually.
|
|
28
|
+
"""
|
|
29
|
+
result: list[int] = [INVALID] * 256
|
|
30
|
+
for i, ch in enumerate(charset):
|
|
31
|
+
result[ord(ch)] = i
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
lookup_table: list[int] = build_lookup_table()
|
|
36
|
+
"""Prebuilt lookup table for ASCII → base32 digit mapping.
|
|
37
|
+
|
|
38
|
+
This table is generated once at import time to provide constant-time
|
|
39
|
+
access for all subsequent reverse lookups.
|
|
40
|
+
|
|
41
|
+
:meta hide-value:
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reverse_lookup(ch: str) -> int | None:
|
|
46
|
+
"""Convert a Nix base32 character to its numeric digit index.
|
|
47
|
+
|
|
48
|
+
Uses :data:`lookup_table` for a direct translation of the character
|
|
49
|
+
to its digit value. Returns ``None`` for invalid characters.
|
|
50
|
+
|
|
51
|
+
:param ch: Single base32 character to convert.
|
|
52
|
+
:type ch: str
|
|
53
|
+
:returns:
|
|
54
|
+
Integer digit index (0..31) or ``None`` if invalid.
|
|
55
|
+
:rtype: int | None
|
|
56
|
+
|
|
57
|
+
:example:
|
|
58
|
+
>>> reverse_lookup("a")
|
|
59
|
+
10
|
|
60
|
+
>>> reverse_lookup("?")
|
|
61
|
+
None
|
|
62
|
+
"""
|
|
63
|
+
if len(ch) != 1:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
codepoint = ord(ch)
|
|
67
|
+
if codepoint >= len(lookup_table):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
digit: int = lookup_table[codepoint]
|
|
71
|
+
return None if digit == INVALID else digit
|
base32/detail/types.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# fmt: off
|
|
2
|
+
"""Type definitions and constants for Nix-style base32 encoding.
|
|
3
|
+
|
|
4
|
+
Defines the Nix-specific alphabet, type aliases for valid characters and
|
|
5
|
+
strings, and validation logic that ensures base32 strings contain only
|
|
6
|
+
permitted symbols.
|
|
7
|
+
|
|
8
|
+
The alphabet used here omits ambiguous letters ("e", "o", "u", "t") to
|
|
9
|
+
reduce transcription errors, following the Nix convention.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from typing import ClassVar, Literal, get_args
|
|
14
|
+
|
|
15
|
+
INVALID: int = 0xFF
|
|
16
|
+
"""Sentinel integer used for invalid base32 character mappings."""
|
|
17
|
+
|
|
18
|
+
# Original reference:
|
|
19
|
+
# https://github.com/NixOS/nix/blob/fb117e0cacc9b0bb29288ee9d3cb6dc0b5ff34a5/src/libutil/include/nix/util/base-nix-32.hh#L17
|
|
20
|
+
# Note: 'e', 'o', 'u', 't' - omitted to avoid ambiguity.
|
|
21
|
+
|
|
22
|
+
type NixBase32Charset = Literal["0123456789abcdfghijklmnpqrsvwxyz"]
|
|
23
|
+
"""Literal of the full concatenated Nix base32 alphabet."""
|
|
24
|
+
|
|
25
|
+
charset: NixBase32Charset = get_args(NixBase32Charset.__value__)[0]
|
|
26
|
+
"""Canonical string representation of the Nix base32 alphabet, in order."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NixBase32Str(str):
|
|
30
|
+
"""Validated string subclass restricted to Nix base32 characters.
|
|
31
|
+
|
|
32
|
+
Instances of this class behave like built-in :class:`str` but cannot
|
|
33
|
+
contain invalid symbols. Construction will raise :class:`ValueError`
|
|
34
|
+
if the input includes any character not in the official Nix alphabet.
|
|
35
|
+
|
|
36
|
+
:param value: base32 string value to validate and store.
|
|
37
|
+
:type value: str
|
|
38
|
+
:raises ValueError: If the input contains any disallowed character.
|
|
39
|
+
|
|
40
|
+
:example:
|
|
41
|
+
>>> NixBase32Str("abc123")
|
|
42
|
+
'abc123'
|
|
43
|
+
>>> NixBase32Str("abcd$") # invalid
|
|
44
|
+
Traceback (most recent call last):
|
|
45
|
+
...
|
|
46
|
+
ValueError: Invalid Nix base32 string: abcd$
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_allowed: ClassVar[set[str]] = set(charset)
|
|
50
|
+
|
|
51
|
+
def __new__(cls, value: str) -> NixBase32Str: # noqa: PYI034
|
|
52
|
+
if not set(value) <= cls._allowed:
|
|
53
|
+
raise ValueError(f"Invalid Nix base32 string: {value}")
|
|
54
|
+
return super().__new__(cls, value)
|
base32/encode.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""base32 encoding following the Nix variant semantics.
|
|
2
|
+
|
|
3
|
+
This module defines :func:`encode`, which converts arbitrary byte
|
|
4
|
+
strings into a :class:`~base32.detail.types.NixBase32Str`.
|
|
5
|
+
|
|
6
|
+
The algorithm mirrors the Nix implementation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, BinaryIO, TextIO
|
|
10
|
+
|
|
11
|
+
from .detail import NixBase32Str, charset, encoded_length
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def encode(bs: bytes) -> NixBase32Str:
|
|
18
|
+
"""Encode a byte sequence into a Nix base32 string.
|
|
19
|
+
|
|
20
|
+
Each group of five bits in ``bs`` is mapped to a single
|
|
21
|
+
character from the Nix base32 alphabet. The result omits
|
|
22
|
+
padding characters and is guaranteed to roundtrip through
|
|
23
|
+
:func:`base32.decode`.
|
|
24
|
+
|
|
25
|
+
:param bs: Bytes to encode.
|
|
26
|
+
:type bs: bytes
|
|
27
|
+
:returns: base32 string representation.
|
|
28
|
+
:rtype: base32.detail.types.NixBase32Str
|
|
29
|
+
|
|
30
|
+
:example:
|
|
31
|
+
>>> from base32 import encode
|
|
32
|
+
>>> encode(b"foo")
|
|
33
|
+
NixBase32Str('mzxw6')
|
|
34
|
+
>>> encode(b"")
|
|
35
|
+
NixBase32Str('')
|
|
36
|
+
.. seealso::
|
|
37
|
+
**Nix reference implementation:**
|
|
38
|
+
https://github.com/NixOS/nix/blob/fb117e0cacc9b0bb29288ee9d3cb6dc0b5ff34a5/src/libutil/base-nix-32.cc#L20
|
|
39
|
+
"""
|
|
40
|
+
if not bs:
|
|
41
|
+
return NixBase32Str("")
|
|
42
|
+
|
|
43
|
+
length = encoded_length(len(bs))
|
|
44
|
+
out: list[str] = []
|
|
45
|
+
|
|
46
|
+
# Walk 5-bit groups from MSB to LSB.
|
|
47
|
+
for n in reversed(range(length)):
|
|
48
|
+
b = n * 5
|
|
49
|
+
i = b // 8
|
|
50
|
+
j = b % 8
|
|
51
|
+
|
|
52
|
+
b1 = bs[i]
|
|
53
|
+
b2 = bs[i + 1] if i + 1 < len(bs) else 0
|
|
54
|
+
c = ((b1 >> j) | ((b2 << (8 - j)) & 0xFF)) & 0xFF
|
|
55
|
+
out.append(charset[c & 0x1F])
|
|
56
|
+
|
|
57
|
+
return NixBase32Str("".join(out))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def encode_iter(bs: bytes, chunk_size: int = 8192) -> Iterator[str]:
|
|
61
|
+
"""Encode bytes and yield output in chunks.
|
|
62
|
+
|
|
63
|
+
This is useful for memory-efficient writing of large encoded data.
|
|
64
|
+
The encoding itself requires the full input, but output is yielded
|
|
65
|
+
in manageable chunks.
|
|
66
|
+
|
|
67
|
+
:param bs: Bytes to encode.
|
|
68
|
+
:param chunk_size: Maximum characters per yielded chunk.
|
|
69
|
+
:yields: String chunks of the encoded output.
|
|
70
|
+
|
|
71
|
+
:example:
|
|
72
|
+
>>> for chunk in encode_iter(b"hello" * 1000, chunk_size=100):
|
|
73
|
+
... print(len(chunk)) # Each chunk <= 100 chars
|
|
74
|
+
"""
|
|
75
|
+
encoded = encode(bs)
|
|
76
|
+
for i in range(0, len(encoded), chunk_size):
|
|
77
|
+
yield str(encoded[i : i + chunk_size])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encode_to_stream(
|
|
81
|
+
bs: bytes,
|
|
82
|
+
writer: TextIO,
|
|
83
|
+
*,
|
|
84
|
+
chunk_size: int = 8192,
|
|
85
|
+
) -> int:
|
|
86
|
+
"""Encode bytes and write to a text stream.
|
|
87
|
+
|
|
88
|
+
:param bs: Bytes to encode.
|
|
89
|
+
:param writer: Text stream to write encoded output to.
|
|
90
|
+
:param chunk_size: Write buffer size in characters.
|
|
91
|
+
:returns: Number of characters written.
|
|
92
|
+
|
|
93
|
+
:example:
|
|
94
|
+
>>> import io
|
|
95
|
+
>>> buf = io.StringIO()
|
|
96
|
+
>>> encode_to_stream(b"hello", buf)
|
|
97
|
+
8
|
|
98
|
+
>>> buf.getvalue()
|
|
99
|
+
'nbswy3dp'
|
|
100
|
+
"""
|
|
101
|
+
written = 0
|
|
102
|
+
for chunk in encode_iter(bs, chunk_size):
|
|
103
|
+
writer.write(chunk)
|
|
104
|
+
written += len(chunk)
|
|
105
|
+
return written
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def encode_stream(
|
|
109
|
+
reader: BinaryIO,
|
|
110
|
+
writer: TextIO,
|
|
111
|
+
*,
|
|
112
|
+
chunk_size: int = 8192,
|
|
113
|
+
) -> int:
|
|
114
|
+
"""Read binary data from a stream, encode, and write to text stream.
|
|
115
|
+
|
|
116
|
+
.. note::
|
|
117
|
+
Due to the Nix base32 algorithm processing bits in reverse order
|
|
118
|
+
(MSB to LSB), the full input must be buffered before encoding.
|
|
119
|
+
This function provides streaming I/O but not streaming computation.
|
|
120
|
+
|
|
121
|
+
:param reader: Binary stream to read input from.
|
|
122
|
+
:param writer: Text stream to write encoded output to.
|
|
123
|
+
:param chunk_size: I/O buffer size.
|
|
124
|
+
:returns: Number of characters written.
|
|
125
|
+
|
|
126
|
+
:example:
|
|
127
|
+
>>> import io
|
|
128
|
+
>>> inp = io.BytesIO(b"hello")
|
|
129
|
+
>>> out = io.StringIO()
|
|
130
|
+
>>> encode_stream(inp, out)
|
|
131
|
+
8
|
|
132
|
+
>>> out.getvalue()
|
|
133
|
+
'nbswy3dp'
|
|
134
|
+
"""
|
|
135
|
+
data = reader.read()
|
|
136
|
+
return encode_to_stream(data, writer, chunk_size=chunk_size)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nix-base32
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Pure Python implementation of nix variant of base32
|
|
5
|
+
Author-email: "Peter A." <ink.splatters@pm.me>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Peter A.
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Classifier: Development Status :: 4 - Beta
|
|
29
|
+
Classifier: Intended Audience :: Developers
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
32
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
33
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
34
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
35
|
+
Requires-Python: >=3.14
|
|
36
|
+
Provides-Extra: cli
|
|
37
|
+
Requires-Dist: click>=8.3.0; extra == 'cli'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# nix‑base32
|
|
41
|
+
|
|
42
|
+
Pure‑Python implementation of the Nix‑specific base32 variant.
|
|
43
|
+
|
|
44
|
+
______________________________________________________________________
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install 'nix-base32[cli] @ git+https://github.com/ink-splatters/nix-base32'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
______________________________________________________________________
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### Python
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from base32 import encode, decode
|
|
60
|
+
|
|
61
|
+
data = b"hello"
|
|
62
|
+
encoded = encode(data)
|
|
63
|
+
print(encoded) # -> NixBase32Str('nbswy3dp')
|
|
64
|
+
print(decode(encoded)) # -> b'hello'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### CLI
|
|
68
|
+
|
|
69
|
+
the CLI was inspired by BSD bintrans' `base64` util (now part of standard macOS distribution).
|
|
70
|
+
|
|
71
|
+
_Examples:_
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
❯ echo de2fc4ce5252da49a272fb22e68c73dcfa12ef08077ac26b40ad3a40dd31376e | base32
|
|
75
|
+
0vip67fl0fmd81mw4yh713pi5ynwff6fc8pvfai4knjjab7c8byy
|
|
76
|
+
|
|
77
|
+
❯ echo 0vip67fl0fmd81mw4yh713pi5ynwff6fc8pvfai4knjjab7c8byy | base32 --sri
|
|
78
|
+
sha256-3i/EzlJS2kmicvsi5oxz3PoS7wgHesJrQK06QN0xN24=
|
|
79
|
+
|
|
80
|
+
❯ echo de2fc4ce5252da49a272fb22e68c73dcfa12ef08077ac26b40ad3a40dd31376e | base32 | base32 -d
|
|
81
|
+
de2fc4ce5252da49a272fb22e68c73dcfa12ef08077ac26b40ad3a40dd31376e
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
______________________________________________________________________
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT — see [LICENSE](LICENSE).
|
|
89
|
+
|
|
90
|
+
## Contributions
|
|
91
|
+
|
|
92
|
+
Contributions are welcome at the author's discretion; please open an issue first.
|
|
93
|
+
See any standard open collaborative code of conduct such as
|
|
94
|
+
[Contributor Covenant](https://www.contributor-covenant.org/).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
base32/__init__.py,sha256=BvePOqVgt46zTmg7LJp7PO2VvsMpyOWMSTH19qvKR30,1155
|
|
2
|
+
base32/decode.py,sha256=qp29n0yHEDbDa3fTX8sPOYBZQ6eFhXGwl_twcvH3zLY,4381
|
|
3
|
+
base32/encode.py,sha256=LWfjsb7JgwypuJvth6YdNUTbe3HxlMeUjEZZNzndB1c,3920
|
|
4
|
+
base32/cli/__init__.py,sha256=UDzlMCCYAy7o0VUF_GHRU96KdXJoTbsclcgcK-Plgpg,1005
|
|
5
|
+
base32/cli/__main__.py,sha256=mzo6ohPJvgG2zMydqiln_PAciPUryZoYmq8siMbJ4i4,160
|
|
6
|
+
base32/cli/io.py,sha256=u19qO2EDiCCeL_G5-2k8xBpN33V6uF1ymlYI4nlDohk,704
|
|
7
|
+
base32/cli/ops.py,sha256=x4uZT4jolIBileuB02-yzD-Yu2gaDeRtqHHHmb6F6Ms,2592
|
|
8
|
+
base32/detail/__init__.py,sha256=lsyKSdi9neCLBh0uPOk-uqev7A81WkPSbwXOYO3oQi8,931
|
|
9
|
+
base32/detail/lengths.py,sha256=vzWGO1xNymTd_astOuqB0O-hmmZQOinAm1RlhOHiiMQ,1788
|
|
10
|
+
base32/detail/reverse_lookup.py,sha256=KPfqgJTMkbZ3XIxsiTj8MWc_OxMZG1mgQlc6RUJHigc,2035
|
|
11
|
+
base32/detail/types.py,sha256=WxJiaiyazmEV7XOnumxYF978PBoihoxQNbIcY4S89bE,1966
|
|
12
|
+
nix_base32-0.4.2.dist-info/METADATA,sha256=RPgIMsoN37nGNJpb7W1Y9fBAf6_g-Kq3QcLzfCLuYeA,3395
|
|
13
|
+
nix_base32-0.4.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
nix_base32-0.4.2.dist-info/entry_points.txt,sha256=dFW5GNmB92k6Q-qAGYBsOueZPaJBAYjeS68gshohRCQ,45
|
|
15
|
+
nix_base32-0.4.2.dist-info/licenses/LICENSE,sha256=yCbF7A9Qpm2aPKI-pOyuORXIcq_TAxy-4pD8pOOhfbU,1065
|
|
16
|
+
nix_base32-0.4.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Peter A.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|