import hashlib import uuid import zipfile import pytest from lxml import etree from src.edition.fonts import ADOBE, IDPF, _key, decoded_fonts, primary_identifier, rebind_fonts OPF = 'http://www.idpf.org/2007/opf' def key(identifier, algorithm): if algorithm == IDPF: return hashlib.sha1(''.join(identifier.split()).encode()).digest() return uuid.UUID(identifier.removeprefix('urn:uuid:')).bytes def xor(data, identifier, algorithm): k = key(identifier, algorithm); n = 1040 if algorithm == IDPF else 1024 return bytes(b ^ k[i % len(k)] for i, b in enumerate(data[:n])) + data[n:] def fixture(tmp_path, algorithm): old = 'urn:uuid:' + str(uuid.UUID('12345678-1234-5678-1234-567812345678')) new = 'urn:uuid:' + str(uuid.UUID('87654321-4321-8765-4321-876543218765')) plain = b'\x00\x01\x00\x00' + bytes(range(256)) * 20 encrypted = xor(plain, old, algorithm) opf = f'''{old}''' enc = f'''''' path = tmp_path / 'book.epub' with zipfile.ZipFile(path, 'w') as z: z.writestr('EPUB/package.opf', opf) z.writestr('META-INF/encryption.xml', enc) z.writestr('EPUB/fonts/a.ttf', encrypted) z.writestr('EPUB/styles/main.css', b'@font-face {src:url("./../fonts/%61.ttf")} /* url(https://example.org/fonts/a.ttf) */') z.writestr('EPUB/text/ch.xhtml', '

x

') root = etree.fromstring(opf.encode()) return path, root, old, new, plain, encrypted @pytest.mark.parametrize('algorithm', [IDPF, ADOBE]) def test_decode_and_rebind_preserves_original_and_rekeys_css_urls(tmp_path, algorithm): path, root, old, new, plain, encrypted = fixture(tmp_path, algorithm) with zipfile.ZipFile(path) as z: assert primary_identifier(root) == old assert decoded_fonts(z, 'EPUB/package.opf', root) == {'EPUB/fonts/a.ttf': plain} additions, replacements = {}, {} records = rebind_fonts(z, 'EPUB/package.opf', root, old, new, additions, replacements) assert len(records) == 1 and records[0]['algorithm'] == algorithm assert records[0]['original_sha256'] == hashlib.sha256(encrypted).hexdigest() assert records[0]['decoded_sha256'] == hashlib.sha256(plain).hexdigest() assert additions['EPUB/fonts/edition-a.ttf'] == xor(plain, new, algorithm) assert z.read('EPUB/fonts/a.ttf') == encrypted assert root.xpath('//*[local-name()="item" and @id="font"]/@href') == ['fonts/edition-a.ttf'] assert root.xpath('//*[local-name()="item" and @id="font-source"]/@media-type') == ['application/octet-stream'] assert b'../fonts/edition-a.ttf' in replacements['EPUB/styles/main.css'] assert b'../fonts/edition-a.ttf' in replacements['EPUB/text/ch.xhtml'] assert b'https://example.org/fonts/a.ttf' in replacements['EPUB/styles/main.css'] assert b'edition-a.ttf' in replacements['EPUB/text/ch.xhtml'] enc = etree.fromstring(replacements['META-INF/encryption.xml']) assert enc.xpath('//*[local-name()="CipherReference"]/@URI') == ['EPUB/fonts/edition-a.ttf'] # The replacement payload decodes under the new primary identifier. root.xpath('//*[local-name()="identifier" and @id="uid"]')[0].text = new replacements['EPUB/package.opf'] = etree.tostring(root) class ZipView: def read(self, name): if name in replacements: return replacements[name] return additions[name] if name in additions else z.read(name) def namelist(self): return z.namelist() + list(additions) view = ZipView() decoded = decoded_fonts(view, 'EPUB/package.opf', root) assert decoded['EPUB/fonts/edition-a.ttf'] == plain def test_rejects_unknown_algorithm_and_non_font_payload(tmp_path): path, root, old, new, plain, encrypted = fixture(tmp_path, 'urn:unknown') with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='Unsupported EPUB encryption algorithm'): decoded_fonts(z, 'EPUB/package.opf', root) path, root, old, new, plain, encrypted = fixture(tmp_path, IDPF) with zipfile.ZipFile(path, 'w') as z: z.writestr('EPUB/package.opf', f'''{old}''') z.writestr('META-INF/encryption.xml', f'''''') z.writestr('EPUB/fonts/a.ttf', xor(b'not-a-font'+bytes(1200), old, IDPF)) root = etree.fromstring(f'''{old}'''.encode()) with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='recognized font signature'): decoded_fonts(z, 'EPUB/package.opf', root) def test_rejects_unsafe_cipher_path(tmp_path): path, root, old, new, plain, encrypted = fixture(tmp_path, IDPF) with zipfile.ZipFile(path, 'w') as z: z.writestr('EPUB/package.opf', f'''{old}''') z.writestr('META-INF/encryption.xml', f'''''') root = etree.fromstring(f'''{old}'''.encode()) with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='escapes EPUB root'): decoded_fonts(z, 'EPUB/package.opf', root) def test_idpf_whitespace_removal_is_limited_to_xml_whitespace(): assert _key('ab c\td\re\nf', IDPF) == hashlib.sha1(b'abcdef').digest() # Unicode whitespace is not part of XML's four whitespace code points. assert _key('ab\u00a0c', IDPF) == hashlib.sha1('ab\u00a0c'.encode()).digest()