v1.1.0 - Added validation and line formatting, fixed zero-length padding. Added tests.
This commit is contained in:
@@ -2,3 +2,4 @@ __pycache__/
|
||||
dist/
|
||||
.*
|
||||
!.gitignore
|
||||
*.lock
|
||||
|
||||
@@ -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.
|
||||
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).
|
||||
|
||||
+33
-12
@@ -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()))
|
||||
|
||||
+2
-2
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user