mailman/templates/mailman-legacy-hyperkitty-c...

308 lines
11 KiB
Django/Jinja

#!/usr/bin/env python3
"""Apply narrowly scoped compatibility fixes to the legacy Mailman web stack."""
from __future__ import print_function
import gettext
import glob
import hashlib
import os
import stat
import struct
import sys
import tempfile
import pkg_resources
EXPECTED_VERSIONS = {
'HyperKitty': {{ mailman_legacy_hyperkitty_version | to_json }},
'django-mailman3': {{ mailman_legacy_django_mailman3_version | to_json }},
'mailman-hyperkitty': {{ mailman_legacy_mailman_hyperkitty_version | to_json }},
}
HYPERKITTY_IMPORT_OLD = "import re\nfrom email.message import EmailMessage\n"
HYPERKITTY_IMPORT_V1 = (
"import re\n"
"from email.header import decode_header, make_header\n"
"from email.message import EmailMessage\n"
)
HYPERKITTY_IMPORT_NEW = (
"import hashlib\n"
"import re\n"
"from email.header import decode_header, make_header\n"
"from email.message import EmailMessage\n"
)
HYPERKITTY_CLASS_MARKER = "class DuplicateMessage(Exception):\n"
HYPERKITTY_HELPER_V1 = '''def _header_to_unicode_safely(message, name):
"""Decode a header even when Python's structured parser rejects it."""
try:
return header_to_unicode(message[name])
except (AttributeError, IndexError, TypeError, ValueError):
raw_value = next(
(value for key, value in message.raw_items()
if key.lower() == name.lower()),
None)
if raw_value is None:
return None
try:
return str(make_header(decode_header(raw_value)))
except (LookupError, UnicodeError):
return raw_value
'''
HYPERKITTY_HELPER = HYPERKITTY_HELPER_V1 + '''def _ascii_sender_address_safely(address, original_header):
"""Return an ASCII sender key without rejecting the whole message."""
address = address.strip()
if not address:
return ''
try:
return address.encode('ascii').decode('ascii')
except UnicodeEncodeError:
pass
local_part, separator, domain = address.rpartition('@')
if separator:
try:
local_part = local_part.encode('ascii').decode('ascii')
domain = domain.encode('idna').decode('ascii')
return '{}@{}'.format(local_part, domain)
except UnicodeError:
pass
if not isinstance(original_header, str):
original_header = repr(original_header)
digest = hashlib.sha256(
original_header.encode('utf-8', 'surrogatepass')).hexdigest()[:16]
return 'unknown-{}@example.invalid'.format(digest)
'''
HYPERKITTY_FROM_OLD = "from_str = header_to_unicode(message['From'])"
HYPERKITTY_FROM_NEW = "from_str = _header_to_unicode_safely(message, 'From')"
HYPERKITTY_SUBJECT_OLD = "email.subject = header_to_unicode(message.get('Subject'))"
HYPERKITTY_SUBJECT_NEW = "email.subject = _header_to_unicode_safely(message, 'Subject')"
HYPERKITTY_SENDER_OLD = ''' try:
from_str = _header_to_unicode_safely(message, 'From')
from_name, from_email = parseaddr(from_str)
from_name = from_name.strip()
sender_address = from_email.encode('ascii').decode("ascii").strip()
except (UnicodeDecodeError, UnicodeEncodeError):
raise ValueError("Non-ascii sender address", message)
'''
HYPERKITTY_SENDER_NEW = ''' from_str = _header_to_unicode_safely(message, 'From')
from_name, from_email = parseaddr(from_str)
from_name = from_name.strip()
sender_address = _ascii_sender_address_safely(from_email, from_str)
'''
SCRUB_OLD = ''' if ctype == 'message/rfc822':
# Return message/rfc822 parts as a string.
decodedpayload = str(payload)
'''
SCRUB_NEW = ''' if ctype == 'message/rfc822':
# Python 3.6 can fail to serialize malformed nested messages that
# do not declare a Content-Transfer-Encoding header.
try:
decodedpayload = str(payload)
except KeyError as error:
if error.args != ('content-transfer-encoding',):
raise
payload['Content-Transfer-Encoding'] = '8bit'
decodedpayload = str(payload)
'''
MAILMAN_HYPERKITTY_SEND_OLD = ''' message_text = msg.as_string()
except (MessageError, KeyError) as error:
'''
MAILMAN_HYPERKITTY_SEND_NEW = ''' message_text = msg.as_bytes()
except (MessageError, KeyError, UnicodeEncodeError) as error:
'''
def distribution(name):
dist = pkg_resources.get_distribution(name)
expected = EXPECTED_VERSIONS[name]
if dist.version != expected:
raise RuntimeError(
'{} {} is installed; compatibility patch requires {}'.format(
name, dist.version, expected))
return dist
def replace_once(source, old, new, description):
count = source.count(old)
if count != 1:
raise RuntimeError(
'{}: expected exactly one unpatched source fragment, found {}'.format(
description, count))
return source.replace(old, new, 1)
def patch_hyperkitty(source):
patched = HYPERKITTY_HELPER in source
completed = (
HYPERKITTY_IMPORT_NEW in source and
HYPERKITTY_FROM_NEW in source and
HYPERKITTY_SUBJECT_NEW in source and
HYPERKITTY_SENDER_NEW in source and
HYPERKITTY_IMPORT_OLD not in source and
HYPERKITTY_FROM_OLD not in source and
HYPERKITTY_SUBJECT_OLD not in source and
HYPERKITTY_SENDER_OLD not in source
)
if patched:
if not completed:
raise RuntimeError('HyperKitty compatibility patch is incomplete')
return source, False
if HYPERKITTY_HELPER_V1 in source:
updated = replace_once(
source, HYPERKITTY_IMPORT_V1, HYPERKITTY_IMPORT_NEW,
'HyperKitty v1 imports')
updated = replace_once(
updated, HYPERKITTY_HELPER_V1, HYPERKITTY_HELPER,
'HyperKitty v1 helper')
else:
updated = replace_once(
source, HYPERKITTY_IMPORT_OLD, HYPERKITTY_IMPORT_NEW,
'HyperKitty imports')
updated = replace_once(
updated, HYPERKITTY_CLASS_MARKER,
HYPERKITTY_HELPER + HYPERKITTY_CLASS_MARKER,
'HyperKitty helper insertion point')
updated = replace_once(
updated, HYPERKITTY_FROM_OLD, HYPERKITTY_FROM_NEW,
'HyperKitty From handling')
updated = replace_once(
updated, HYPERKITTY_SUBJECT_OLD, HYPERKITTY_SUBJECT_NEW,
'HyperKitty Subject handling')
updated = replace_once(
updated, HYPERKITTY_SENDER_OLD, HYPERKITTY_SENDER_NEW,
'HyperKitty sender address handling')
return updated, True
def patch_scrubber(source):
if SCRUB_NEW in source:
if SCRUB_OLD in source:
raise RuntimeError('django-mailman3 compatibility patch is ambiguous')
return source, False
return replace_once(
source, SCRUB_OLD, SCRUB_NEW,
'django-mailman3 message/rfc822 handling'), True
def patch_mailman_hyperkitty(source):
if MAILMAN_HYPERKITTY_SEND_NEW in source:
if MAILMAN_HYPERKITTY_SEND_OLD in source:
raise RuntimeError('mailman-hyperkitty compatibility patch is ambiguous')
return source, False
return replace_once(
source, MAILMAN_HYPERKITTY_SEND_OLD, MAILMAN_HYPERKITTY_SEND_NEW,
'mailman-hyperkitty byte delivery'), True
def load_source(path):
with open(path, 'rb') as source_file:
return source_file.read().decode('utf-8')
def atomic_write(path, content):
current = os.stat(path)
directory = os.path.dirname(path)
descriptor, temporary = tempfile.mkstemp(prefix='.mailman-patch-', dir=directory)
try:
with os.fdopen(descriptor, 'wb') as output:
output.write(content.encode('utf-8'))
output.flush()
os.fsync(output.fileno())
os.chmod(temporary, stat.S_IMODE(current.st_mode))
os.chown(temporary, current.st_uid, current.st_gid)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def invalid_catalogs(django_extensions_dir):
invalid = []
pattern = os.path.join(django_extensions_dir, 'locale', '**', '*.mo')
for path in glob.iglob(pattern, recursive=True):
try:
with open(path, 'rb') as catalog:
gettext.GNUTranslations(catalog)
except (EOFError, OSError, UnicodeError, struct.error, ValueError) as error:
invalid.append((path, error))
return invalid
def quarantine_catalog(path):
with open(path, 'rb') as catalog:
digest = hashlib.sha256(catalog.read()).hexdigest()[:12]
destination = path + '.disabled-by-ansible'
if os.path.exists(destination):
destination = destination + '.' + digest
if os.path.exists(destination):
raise RuntimeError(
'invalid gettext catalog is still active and already quarantined: '
+ path)
os.rename(path, destination)
return destination
def main():
hyperkitty = distribution('HyperKitty')
django_mailman3 = distribution('django-mailman3')
mailman_hyperkitty = distribution('mailman-hyperkitty')
incoming_path = os.path.join(hyperkitty.location, 'hyperkitty', 'lib', 'incoming.py')
scrub_path = os.path.join(
django_mailman3.location, 'django_mailman3', 'lib', 'scrub.py')
mailman_hyperkitty_path = os.path.join(
mailman_hyperkitty.location, 'mailman_hyperkitty', '__init__.py')
incoming_source, incoming_changed = patch_hyperkitty(load_source(incoming_path))
scrub_source, scrub_changed = patch_scrubber(load_source(scrub_path))
mailman_hyperkitty_source, mailman_hyperkitty_changed = (
patch_mailman_hyperkitty(load_source(mailman_hyperkitty_path)))
# Compile all transformed files before replacing any one of them.
compile(incoming_source, incoming_path, 'exec')
compile(scrub_source, scrub_path, 'exec')
compile(mailman_hyperkitty_source, mailman_hyperkitty_path, 'exec')
try:
import django_extensions
except ImportError:
django_extensions = None
catalogs = [] if django_extensions is None else invalid_catalogs(
os.path.dirname(django_extensions.__file__))
if incoming_changed:
atomic_write(incoming_path, incoming_source)
print('CHANGED: patched {}'.format(incoming_path))
if scrub_changed:
atomic_write(scrub_path, scrub_source)
print('CHANGED: patched {}'.format(scrub_path))
if mailman_hyperkitty_changed:
atomic_write(mailman_hyperkitty_path, mailman_hyperkitty_source)
print('CHANGED: patched {}'.format(mailman_hyperkitty_path))
for catalog_path, error in catalogs:
destination = quarantine_catalog(catalog_path)
print('CHANGED: quarantined {} as {} ({})'.format(
catalog_path, destination, error))
if (not incoming_changed and not scrub_changed and
not mailman_hyperkitty_changed and not catalogs):
print('OK: compatibility fixes are already applied')
if __name__ == '__main__':
try:
main()
except Exception as error:
print('ERROR: {}'.format(error), file=sys.stderr)
sys.exit(1)