From db4f5e5ac22aa42ef71df21df39f49cec3309530 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 12:32:27 +0000 Subject: [PATCH] v1.1.0 - Added validation and line formatting, fixed zero-length padding. Added tests. --- .gitignore | 1 + README.md | 14 +++- base64url.py | 45 +++++++--- pyproject.toml | 4 +- test_base64url.py | 206 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 17 deletions(-) create mode 100644 test_base64url.py diff --git a/.gitignore b/.gitignore index 56a5ba1..fba1f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ dist/ .* !.gitignore +*.lock diff --git a/README.md b/README.md index fa447ee..916b5ad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Replaces the standard library's `base64.urlsafe_b64encode` and `base64.urlsafe_b ## Features -- **URL safe**: Uses only characters that are safe for URLs and filenames +- **Urlsafe**: Uses only characters that are safe for URLs and filenames - **No padding**: Removes trailing `=` characters for cleaner output - **String output**: Returns proper strings instead of bytes (unlike Python's standard library) - **Fast**: Based on Python stdlib, with constant-time padding restoration @@ -35,6 +35,14 @@ data = base64url.dec(text) # Recovers the bytes Base64 encode bytes to a URL-safe string without padding. -### `dec(s: str) -> bytes` +### `dec(s: str, *, validate=True) -> bytes` -Decode URL-safe Base64 into bytes. Padding optional. \ No newline at end of file +Decode URL-safe Base64 into bytes. Padding optional. Set `validate=False` to silently ignore any unknown characters (Python base64 default behavior). + +### `enc_lines(data: bytes, length: int = 76) -> str` + +Encode with newlines inserted every `length` characters. + +### `dec_lines(s: str) -> bytes` + +Decode formatted input, ignoring all whitespace (but not other unknown characters). diff --git a/base64url.py b/base64url.py index ea6bcd0..dd1772f 100644 --- a/base64url.py +++ b/base64url.py @@ -1,18 +1,16 @@ -"""URL-safe Base64 encoding without padding. - -This format uses only characters that are safe for URLs, filenames etc. -Refer to Python's urlsafe_b64encode and base64.urlsafe_b64decode for more details. +"""Base64 encoding in urlsafe format without padding. Fixes Python base64 module's faults: -- Padding "==" is optional and not produced by b64url.enc -- Base64 type is str (stdlib is retarded and uses bytes) -- Otherwise identical to urlsafe_b64encode/urlsafe_b64decode +- Padding "==" is optional and not produced by base64url.enc +- Base64 type is str (stdlib uses bytes) +- Doesn't silently ignore invalid characters and produce garbage """ -from base64 import urlsafe_b64decode as _decode +# We cannot use urlsafe_b64decode which misses the validation argument +from base64 import b64decode as _decode from base64 import urlsafe_b64encode as _encode -__all__ = ["enc", "dec"] +__all__ = ["enc", "dec", "enc_lines", "dec_lines"] def enc(data: bytes) -> str: @@ -20,7 +18,30 @@ def enc(data: bytes) -> str: return _encode(data).decode("ascii").rstrip("=") -def dec(s: str) -> bytes: - """Decode URL-safe Base64 into bytes. Padding optional.""" +def dec(s: str, *, validate=True) -> bytes: + """Decode standard or urlsafe Base64, with or without padding. + + Validation raises binascii.Error (ValueError) on invalid characters. + If False, any unrecognized characters are silently ignored. + """ # Testing whether needs padding is slower, this is the fastest way and constant time. - return _decode(s + "=" * (4 - (len(s) % 4))) + return _decode(s + -len(s) % 4 * "=", altchars=b"-_", validate=validate) + + +def enc_lines(data: bytes, length: int = 76, sep="\n") -> str: + """Base64 encode bytes to a URL-safe string split into lines. + + Returns a string with newlines inserted after every `length` characters. + The value should be a multiple of 4 for best compatibility with other systems. + """ + s = enc(data) + return sep.join([s[i : i + length] for i in range(0, len(s), length)]) + + +def dec_lines(s: str) -> bytes: + """Decode standard or urlsafe Base64 from a multiline string. + + All whitespace is ignored to allow formatted input. + Raises binascii.Error (ValueError) on other invalid characters. + """ + return dec("".join(s.split())) diff --git a/pyproject.toml b/pyproject.toml index df5ffde..d8b8071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "base64url" -version = "1.0.0" -description = "Base64 encoding without Python's base64 flaws. No padding, str types." +version = "1.1.0" +description = "Base64 encoding without Python's base64 flaws. Output urlsafe without padding, as str. Decodes any variant with or without padding, with validation." readme = "README.md" requires-python = ">=3.8" dependencies = [] diff --git a/test_base64url.py b/test_base64url.py new file mode 100644 index 0000000..3d5a451 --- /dev/null +++ b/test_base64url.py @@ -0,0 +1,206 @@ +"""Tests for base64url module.""" + +import binascii + +import pytest + +import base64url + + +class TestEnc: + """Tests for enc() function.""" + + def test_empty(self): + assert base64url.enc(b"") == "" + + def test_single_byte(self): + assert base64url.enc(b"\x00") == "AA" + + def test_no_padding_needed(self): + # 3 bytes encodes to 4 chars, no padding + assert base64url.enc(b"abc") == "YWJj" + + def test_one_padding_stripped(self): + # 2 bytes encodes to 3 chars + 1 padding + assert base64url.enc(b"ab") == "YWI" + assert "=" not in base64url.enc(b"ab") + + def test_two_padding_stripped(self): + # 1 byte encodes to 2 chars + 2 padding + assert base64url.enc(b"a") == "YQ" + assert "=" not in base64url.enc(b"a") + + def test_urlsafe_chars(self): + # Bytes that produce + and / in standard Base64 should use - and _ + # 0xfb -> standard: +, urlsafe: - + # 0xff -> standard: /, urlsafe: _ + data = b"\xfb\xff\xfe" + result = base64url.enc(data) + assert "+" not in result + assert "/" not in result + assert "-" in result or "_" in result + + def test_all_bytes(self): + # Verify all byte values encode/decode correctly + data = bytes(range(256)) + encoded = base64url.enc(data) + assert "+" not in encoded + assert "/" not in encoded + assert "=" not in encoded + + +class TestDec: + """Tests for dec() function.""" + + def test_empty(self): + assert base64url.dec("") == b"" + + def test_single_byte(self): + assert base64url.dec("AA") == b"\x00" + + def test_without_padding(self): + assert base64url.dec("YWJj") == b"abc" + assert base64url.dec("YWI") == b"ab" + assert base64url.dec("YQ") == b"a" + + def test_with_padding(self): + # Padding should be optional + assert base64url.dec("YWI=") == b"ab" + assert base64url.dec("YQ==") == b"a" + + def test_urlsafe_chars(self): + # Should accept - instead of +, _ instead of / + assert base64url.dec("--__") == base64url.dec("++//") + + def test_standard_base64_chars(self): + # Should also accept standard Base64 with + and / + assert base64url.dec("++//") == b"\xfb\xef\xff" + + def test_mixed_chars(self): + # Mix of standard and urlsafe should work + assert base64url.dec("+-/_") is not None + + def test_invalid_char_raises(self): + # Invalid characters should raise binascii.Error + with pytest.raises(binascii.Error): + base64url.dec("!!!!") + + def test_invalid_char_in_middle_raises(self): + with pytest.raises(binascii.Error): + base64url.dec("YW!j") + + def test_whitespace_raises(self): + # Whitespace should not be silently ignored + with pytest.raises(binascii.Error): + base64url.dec("YW Jj") + + def test_newline_raises(self): + with pytest.raises(binascii.Error): + base64url.dec("YWJj\n") + + +class TestRoundtrip: + """Tests for enc/dec roundtrip.""" + + def test_empty_roundtrip(self): + assert base64url.dec(base64url.enc(b"")) == b"" + + def test_hello_roundtrip(self): + assert base64url.dec(base64url.enc(b"hello")) == b"hello" + + def test_binary_roundtrip(self): + data = bytes(range(256)) + assert base64url.dec(base64url.enc(data)) == data + + def test_various_lengths(self): + # Test lengths 0-20 to cover all padding scenarios + for i in range(21): + data = bytes(range(i)) + assert base64url.dec(base64url.enc(data)) == data + + def test_random_bytes(self): + import os + + for _ in range(10): + data = os.urandom(100) + assert base64url.dec(base64url.enc(data)) == data + + +class TestEncLines: + """Tests for enc_lines() function.""" + + def test_empty(self): + assert base64url.enc_lines(b"") == "" + + def test_short_no_wrap(self): + # Short data fits on one line + result = base64url.enc_lines(b"hello", length=76) + assert "\n" not in result + assert result == "aGVsbG8" + + def test_wrapping(self): + # 60 bytes -> 80 base64 chars, should wrap at 76 + data = b"x" * 60 + result = base64url.enc_lines(data, length=76) + lines = result.split("\n") + assert len(lines) == 2 + assert len(lines[0]) == 76 + assert len(lines[1]) == 4 + + def test_custom_line_length(self): + data = b"hello world" + result = base64url.enc_lines(data, length=4) + lines = result.split("\n") + assert all(len(line) <= 4 for line in lines) + + def test_exact_multiple(self): + # 6 bytes -> 8 base64 chars, exactly 2 lines of 4 + data = b"abcdef" + result = base64url.enc_lines(data, length=4) + lines = result.split("\n") + assert len(lines) == 2 + assert all(len(line) == 4 for line in lines) + + +class TestDecLines: + """Tests for dec_lines() function.""" + + def test_empty(self): + assert base64url.dec_lines("") == b"" + + def test_single_line(self): + assert base64url.dec_lines("aGVsbG8") == b"hello" + + def test_multiline(self): + multiline = "aGVs\nbG8" + assert base64url.dec_lines(multiline) == b"hello" + + def test_spaces(self): + spaced = "aGVs bG8" + assert base64url.dec_lines(spaced) == b"hello" + + def test_tabs(self): + tabbed = "aGVs\tbG8" + assert base64url.dec_lines(tabbed) == b"hello" + + def test_mixed_whitespace(self): + mixed = " aGVs\n\t bG8 \n" + assert base64url.dec_lines(mixed) == b"hello" + + def test_crlf(self): + crlf = "aGVs\r\nbG8" + assert base64url.dec_lines(crlf) == b"hello" + + +class TestLinesRoundtrip: + """Tests for enc_lines/dec_lines roundtrip.""" + + def test_roundtrip(self): + data = bytes(range(256)) + assert base64url.dec_lines(base64url.enc_lines(data)) == data + + def test_roundtrip_various_line_lengths(self): + data = b"hello world" * 10 + for line_length in [4, 16, 64, 76, 100]: + encoded = base64url.enc_lines(data, length=line_length) + assert base64url.dec_lines(encoded) == data