diff options
Diffstat (limited to 'src/mailman/utilities')
| -rw-r--r-- | src/mailman/utilities/i18n.py | 196 | ||||
| -rw-r--r-- | src/mailman/utilities/passwords.py | 317 | ||||
| -rw-r--r-- | src/mailman/utilities/string.py | 172 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_passwords.py | 150 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_templates.py | 287 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_wrap.py | 151 |
6 files changed, 1271 insertions, 2 deletions
diff --git a/src/mailman/utilities/i18n.py b/src/mailman/utilities/i18n.py new file mode 100644 index 000000000..8e769329c --- /dev/null +++ b/src/mailman/utilities/i18n.py @@ -0,0 +1,196 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""i18n template search and interpolation.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'TemplateNotFoundError', + 'find', + 'make', + ] + + +import os +import errno + +from itertools import product + +from mailman.config import config +from mailman.core.constants import system_preferences +from mailman.core.i18n import _ +from mailman.utilities.string import expand, wrap as wrap_text + + + +class TemplateNotFoundError(Exception): + """The named template was not found.""" + + def __init__(self, template_file): + self.template_file = template_file + + + +def _search(template_file, mailing_list=None, language=None): + """Generator that provides file system search order.""" + + languages = ['en', system_preferences.preferred_language.code] + if mailing_list is not None: + languages.append(mailing_list.preferred_language.code) + if language is not None: + languages.append(language) + languages.reverse() + # File system locations to search. + paths = [config.TEMPLATE_DIR, + os.path.join(config.TEMPLATE_DIR, 'site')] + if mailing_list is not None: + paths.append(os.path.join(config.TEMPLATE_DIR, + mailing_list.host_name)) + paths.append(os.path.join(config.LIST_DATA_DIR, + mailing_list.fqdn_listname)) + paths.reverse() + for language, path in product(languages, paths): + yield os.path.join(path, language, template_file) + + + +def find(template_file, mailing_list=None, language=None): + """Locate an i18n template file. + + When something in Mailman needs a template file, it always asks for the + file through this interface. The results of the search is path to the + 'matching' template, with the search order depending on whether + `mailing_list` and `language` are provided. + + When looking for a template in a specific language, there are 4 locations + that are searched, in this order: + + * The list-specific language directory + <var_dir>/lists/<fqdn_listname>/<language> + + * The domain-specific language directory + <template_dir>/<list-host-name>/<language> + + * The site-wide language directory + <template_dir>/site/<language> + + * The global default language directory + <template_dir>/<language> + + The first match stops the search. In this way, you can specialize + templates at the desired level, or if you only use the default templates, + you don't need to change anything. NEVER modify files in + <template_dir>/<language> since Mailman will overwrite these when you + upgrade. Instead you can use <template_dir>/site. + + The <language> path component is calculated as follows, in this order: + + * The `language` parameter if given + * `mailing_list.preferred_language` if given + * The server's default language + * English ('en') + + Languages are iterated after each of the four locations are searched. So + for example, when searching for the 'foo.txt' template, where the server's + default language is 'fr', the mailing list's (test@example.com) language + is 'de' and the `language` parameter is 'it', these locations are searched + in order: + + * <var_dir>/lists/test@example.com/it/foo.txt + * <template_dir>/example.com/it/foo.txt + * <template_dir>/site/it/foo.txt + * <template_dir>/it/foo.txt + + * <var_dir>/lists/test@example.com/de/foo.txt + * <template_dir>/example.com/de/foo.txt + * <template_dir>/site/de/foo.txt + * <template_dir>/de/foo.txt + + * <var_dir>/lists/test@example.com/fr/foo.txt + * <template_dir>/example.com/fr/foo.txt + * <template_dir>/site/fr/foo.txt + * <template_dir>/fr/foo.txt + + * <var_dir>/lists/test@example.com/en/foo.txt + * <template_dir>/example.com/en/foo.txt + * <template_dir>/site/en/foo.txt + * <template_dir>/en/foo.txt + + :param template_file: The name of the template file to search for. + :type template_file: string + :param mailing_list: Optional mailing list used as the context for + searching for the template file. The list's preferred language will + influence the search, as will the list's data directory. + :type mailing_list: `IMailingList` + :param language: Optional language code, which influences the search. + :type language: string + :return: A tuple of the file system path to the first matching template, + and an open file object allowing reading of the file. + :rtype: (string, file) + :raises TemplateNotFoundError: when the template could not be found. + """ + raw_search_order = _search(template_file, mailing_list, language) + for path in raw_search_order: + try: + fp = open(path) + except IOError as error: + if error.errno != errno.ENOENT: + raise + else: + return path, fp + raise TemplateNotFoundError(template_file) + + +def make(template_file, mailing_list=None, language=None, wrap=True, **kw): + """Locate and 'make' a template file. + + The template file is located as with `find()`, and the resulting text is + optionally wrapped and interpolated with the keyword argument dictionary. + + :param template_file: The name of the template file to search for. + :type template_file: string + :param mailing_list: Optional mailing list used as the context for + searching for the template file. The list's preferred language will + influence the search, as will the list's data directory. + :type mailing_list: `IMailingList` + :param language: Optional language code, which influences the search. + :type language: string + :param wrap: When True, wrap the text. + :type wrap: bool + :param **kw: Keyword arguments for template interpolation. + :return: A tuple of the file system path to the first matching template, + and an open file object allowing reading of the file. + :rtype: (string, file) + :raises TemplateNotFoundError: when the template could not be found. + """ + path, fp = find(template_file, mailing_list, language) + try: + # XXX Removing the trailing newline is a hack carried over from + # Mailman 2. The (stripped) template text is then passed through the + # translation catalog. This ensures that the translated text is + # unicode, and also allows for volunteers to translate the templates + # into the language catalogs. + template = _(fp.read()[:-1]) + finally: + fp.close() + assert isinstance(template, unicode), 'Translated template is not unicode' + text = expand(template, kw) + if wrap: + return wrap_text(text) + return text diff --git a/src/mailman/utilities/passwords.py b/src/mailman/utilities/passwords.py new file mode 100644 index 000000000..c14584748 --- /dev/null +++ b/src/mailman/utilities/passwords.py @@ -0,0 +1,317 @@ +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Password hashing and verification schemes. + +Represents passwords using RFC 2307 syntax (as best we can tell). +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Schemes', + 'check_response', + 'make_secret', + ] + + +import os +import re +import hmac +import hashlib + +from array import array +from base64 import urlsafe_b64decode as decode +from base64 import urlsafe_b64encode as encode +from flufl.enum import Enum + +from mailman.core import errors + +SALT_LENGTH = 20 # bytes +ITERATIONS = 2000 + + + +# pylint: disable-msg=W0232 +class PasswordScheme: + """Password scheme base class.""" + TAG = b'' + + @staticmethod + def make_secret(password): + """Return the hashed password. + + :param password: The clear text password. + :type password: string + :return: The encrypted password. + :rtype: string + """ + raise NotImplementedError + + @staticmethod + def check_response(challenge, response): + """Check a response against a challenge. + + It is expected that the scheme specifier prefix is already stripped + from the response string. + + :param challenge: The challenge. + :type challenge: string + :param response: The response. + :type response: string + :return: True if the response matches the challenge. + :rtype: bool + """ + raise NotImplementedError + + + +class NoPasswordScheme(PasswordScheme): + """A password scheme without passwords.""" + + TAG = b'NONE' + + @staticmethod + def make_secret(password): + """See `PasswordScheme`.""" + return b'' + + # pylint: disable-msg=W0613 + @staticmethod + def check_response(challenge, response): + """See `PasswordScheme`.""" + return False + + + +class ClearTextPasswordScheme(PasswordScheme): + """A password scheme that stores clear text passwords.""" + + TAG = b'CLEARTEXT' + + @staticmethod + def make_secret(password): + """See `PasswordScheme`.""" + return password + + @staticmethod + def check_response(challenge, response): + """See `PasswordScheme`.""" + return challenge == response + + + +class SHAPasswordScheme(PasswordScheme): + """A password scheme that encodes the password using SHA1.""" + + TAG = b'SHA' + + @staticmethod + def make_secret(password): + """See `PasswordScheme`.""" + h = hashlib.sha1(password) + return encode(h.digest()) + + @staticmethod + def check_response(challenge, response): + """See `PasswordScheme`.""" + h = hashlib.sha1(response) + return challenge == encode(h.digest()) + + + +class SSHAPasswordScheme(PasswordScheme): + """A password scheme that encodes the password using salted SHA1.""" + + TAG = b'SSHA' + + @staticmethod + def make_secret(password): + """See `PasswordScheme`.""" + salt = os.urandom(SALT_LENGTH) + h = hashlib.sha1(password) + h.update(salt) + return encode(h.digest() + salt) + + @staticmethod + def check_response(challenge, response): + """See `PasswordScheme`.""" + # Get the salt from the challenge + challenge_bytes = decode(challenge) + digest = challenge_bytes[:20] + salt = challenge_bytes[20:] + h = hashlib.sha1(response) + h.update(salt) + return digest == h.digest() + + + +# Basic algorithm given by Bob Fleck +class PBKDF2PasswordScheme(PasswordScheme): + """RFC 2989 password encoding scheme.""" + + # This is a bit nasty if we wanted a different prf or iterations. OTOH, + # we really have no clue what the standard LDAP-ish specification for + # those options is. + TAG = b'PBKDF2 SHA {0}'.format(ITERATIONS) + + @staticmethod + def _pbkdf2(password, salt, iterations): + """From RFC2898 sec. 5.2. Simplified to handle only 20 byte output + case. Output of 20 bytes means always exactly one block to handle, + and a constant block counter appended to the salt in the initial hmac + update. + """ + h = hmac.new(password, None, hashlib.sha1) + prf = h.copy() + prf.update(salt + b'\x00\x00\x00\x01') + T = U = array(b'i', prf.digest()) + while iterations: + prf = h.copy() + prf.update(U.tostring()) + U = array(b'i', prf.digest()) + T = array(b'i', (t ^ u for t, u in zip(T, U))) + iterations -= 1 + return T.tostring() + + @staticmethod + def make_secret(password): + """See `PasswordScheme`. + + From RFC2898 sec. 5.2. Simplified to handle only 20 byte output + case. Output of 20 bytes means always exactly one block to handle, + and a constant block counter appended to the salt in the initial hmac + update. + """ + salt = os.urandom(SALT_LENGTH) + digest = PBKDF2PasswordScheme._pbkdf2(password, salt, ITERATIONS) + derived_key = encode(digest + salt) + return derived_key + + # pylint: disable-msg=W0221 + @staticmethod + def check_response(challenge, response, prf, iterations): + """See `PasswordScheme`.""" + # Decode the challenge to get the number of iterations and salt. We + # don't support anything but SHA PRF. + if prf.lower() != b'sha': + return False + try: + iterations = int(iterations) + except (ValueError, TypeError): + return False + challenge_bytes = decode(challenge) + digest = challenge_bytes[:20] + salt = challenge_bytes[20:] + key = PBKDF2PasswordScheme._pbkdf2(response, salt, iterations) + return digest == key + + + +class Schemes(Enum): + """List of password schemes.""" + # no_scheme is deliberately ugly because no one should be using it. Yes, + # this makes cleartext inconsistent, but that's a common enough + # terminology to justify the missing underscore. + no_scheme = 1 + cleartext = 2 + sha = 3 + ssha = 4 + pbkdf2 = 5 + + +_SCHEMES_BY_ENUM = { + Schemes.no_scheme : NoPasswordScheme, + Schemes.cleartext : ClearTextPasswordScheme, + Schemes.sha : SHAPasswordScheme, + Schemes.ssha : SSHAPasswordScheme, + Schemes.pbkdf2 : PBKDF2PasswordScheme, + } + + +# Some scheme tags have arguments, but the key for this dictionary should just +# be the lowercased scheme name. +_SCHEMES_BY_TAG = dict((_SCHEMES_BY_ENUM[e].TAG.split(' ')[0].lower(), e) + for e in _SCHEMES_BY_ENUM) + +_DEFAULT_SCHEME = NoPasswordScheme + + + +def make_secret(password, scheme=None): + """Encrypt a password. + + :param password: The clear text password. + :type password: string + :param scheme: The password scheme name. + :type scheme: string + :return: The encrypted password. + :rtype: string + """ + # The hash algorithms operate on bytes not strings. The password argument + # as provided here by the client will be a string (in Python 2 either + # unicode or 8-bit, in Python 3 always unicode). We need to encode this + # string into a byte array, and the way to spell that in Python 2 is to + # encode the string to utf-8. The returned secret is a string, so it must + # be a unicode. + if isinstance(password, unicode): + password = password.encode('utf-8') + scheme_class = _SCHEMES_BY_ENUM.get(scheme) + if not scheme_class: + raise errors.BadPasswordSchemeError(scheme) + secret = scheme_class.make_secret(password) + return b'{{{0}}}{1}'.format(scheme_class.TAG, secret) + + +def check_response(challenge, response): + """Check a response against a challenge. + + :param challenge: The challenge. + :type challenge: string + :param response: The response. + :type response: string + :return: True if the response matches the challenge. + :rtype: bool + """ + mo = re.match(r'{(?P<scheme>[^}]+?)}(?P<rest>.*)', + challenge, re.IGNORECASE) + if not mo: + return False + # See above for why we convert here. However because we should have + # generated the challenge, we assume that it is already a byte string. + if isinstance(response, unicode): + response = response.encode('utf-8') + scheme_group, rest_group = mo.group('scheme', 'rest') + scheme_parts = scheme_group.split() + scheme = scheme_parts[0].lower() + scheme_enum = _SCHEMES_BY_TAG.get(scheme, _DEFAULT_SCHEME) + scheme_class = _SCHEMES_BY_ENUM[scheme_enum] + if isinstance(rest_group, unicode): + rest_group = rest_group.encode('utf-8') + return scheme_class.check_response(rest_group, response, *scheme_parts[1:]) + + +def lookup_scheme(scheme_name): + """Look up a password scheme. + + :param scheme_name: The password scheme name. + :type scheme_name: string + :return: Password scheme class. + :rtype: `PasswordScheme` + """ + return _SCHEMES_BY_TAG.get(scheme_name.lower()) diff --git a/src/mailman/utilities/string.py b/src/mailman/utilities/string.py index 44b99876e..9054ed076 100644 --- a/src/mailman/utilities/string.py +++ b/src/mailman/utilities/string.py @@ -21,12 +21,28 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'expand' + 'expand', + 'oneline', + 'uncanonstr', + 'websafe', + 'wrap', ] +import cgi import logging -from string import Template + +from email.errors import HeaderParseError +from email.header import decode_header, make_header +from string import Template, whitespace +from textwrap import TextWrapper, dedent +from zope.component import getUtility + +from mailman.interfaces.languages import ILanguageManager + + +EMPTYSTRING = '' +NL = '\n' log = logging.getLogger('mailman.error') @@ -57,3 +73,155 @@ def expand(template, substitutions, template_class=Template): except (TypeError, ValueError): # The template is really screwed up. log.exception('broken template: %s', template) + + + +def oneline(s, cset='us-ascii', in_unicode=False): + """Decode a header string in one line and convert into specified charset. + + :param s: The header string + :type s: string + :param cset: The character set (encoding) to use. + :type cset: string + :param in_unicode: Flag specifying whether to return the converted string + as a unicode (True) or an 8-bit string (False, the default). + :type in_unicode: bool + :return: The decoded header string. If an error occurs while converting + the input string, return the string undecoded, as an 8-bit string. + :rtype: string + """ + try: + h = make_header(decode_header(s)) + ustr = h.__unicode__() + line = EMPTYSTRING.join(ustr.splitlines()) + if in_unicode: + return line + else: + return line.encode(cset, 'replace') + except (LookupError, UnicodeError, ValueError, HeaderParseError): + # possibly charset problem. return with undecoded string in one line. + return EMPTYSTRING.join(s.splitlines()) + + + +def websafe(s): + return cgi.escape(s, quote=True) + + + +# The opposite of canonstr() -- sorta. I.e. it attempts to encode s in the +# charset of the given language, which is the character set that the page will +# be rendered in, and failing that, replaces non-ASCII characters with their +# html references. It always returns a byte string. +def uncanonstr(s, lang=None): + if s is None: + s = u'' + if lang is None: + charset = 'us-ascii' + else: + charset = getUtility(ILanguageManager)[lang].charset + # See if the string contains characters only in the desired character + # set. If so, return it unchanged, except for coercing it to a byte + # string. + try: + if isinstance(s, unicode): + return s.encode(charset) + else: + unicode(s, charset) + return s + except UnicodeError: + # Nope, it contains funny characters, so html-ref it + a = [] + for c in s: + o = ord(c) + if o > 127: + a.append('&#%3d;' % o) + else: + a.append(c) + # Join characters together and coerce to byte string + return str(EMPTYSTRING.join(a)) + + + +def wrap(text, column=70, honor_leading_ws=True): + """Wrap and fill the text to the specified column. + + The input text is wrapped and filled as done by the standard library + textwrap module. The differences here being that this function is capable + of filling multiple paragraphs (as defined by text separated by blank + lines). Also, when `honor_leading_ws` is True (the default), paragraphs + that being with whitespace are not wrapped. This is the algorithm that + the Python FAQ wizard used. + """ + # First, split the original text into paragraph, keeping all blank lines + # between them. + paragraphs = [] + paragraph = [] + last_indented = False + for line in text.splitlines(True): + is_indented = (len(line) > 0 and line[0] in whitespace) + if line == NL: + if len(paragraph) > 0: + paragraphs.append(EMPTYSTRING.join(paragraph)) + paragraphs.append(line) + last_indented = False + paragraph = [] + elif last_indented != is_indented: + # The indentation level changed. We treat this as a paragraph + # break but no blank line will be issued between paragraphs. + if len(paragraph) > 0: + paragraphs.append(EMPTYSTRING.join(paragraph)) + # The next paragraph starts with this line. + paragraph = [line] + last_indented = is_indented + else: + # This line does not constitute a paragraph break. + paragraph.append(line) + # We've consumed all the lines in the original text. Transfer the last + # paragraph we were collecting to the full set of paragraphs. + paragraphs.append(EMPTYSTRING.join(paragraph)) + # Now iterate through all paragraphs, wrapping as necessary. + wrapped_paragraphs = [] + # The dedented wrapper. + wrapper = TextWrapper(width=column, + fix_sentence_endings=True) + # The indented wrapper. For this one, we'll clobber initial_indent and + # subsequent_indent as needed per indented chunk of text. + iwrapper = TextWrapper(width=column, + fix_sentence_endings=True, + ) + add_paragraph_break = False + for paragraph in paragraphs: + if add_paragraph_break: + wrapped_paragraphs.append(NL) + add_paragraph_break = False + paragraph_text = EMPTYSTRING.join(paragraph) + # Just copy the blank lines to the final set of paragraphs. + if paragraph == NL: + wrapped_paragraphs.append(NL) + # Choose the wrapper based on whether the paragraph is indented or + # not. Also, do not wrap indented paragraphs if honor_leading_ws is + # set. + elif paragraph[0] in whitespace: + if honor_leading_ws: + # Leave the indented paragraph verbatim. + wrapped_paragraphs.append(paragraph_text) + else: + # The paragraph should be wrapped, but it must first be + # dedented. The leading whitespace on the first line of the + # original text will be used as the indentation for all lines + # in the wrapped text. + for i, ch in enumerate(paragraph_text): + if ch not in whitespace: + break + leading_ws = paragraph[:i] + iwrapper.initial_indent=leading_ws + iwrapper.subsequent_indent=leading_ws + paragraph_text = dedent(paragraph_text) + wrapped_paragraphs.append(iwrapper.fill(paragraph_text)) + add_paragraph_break = True + else: + # Fill this paragraph. fill() consumes the trailing newline. + wrapped_paragraphs.append(wrapper.fill(paragraph_text)) + add_paragraph_break = True + return EMPTYSTRING.join(wrapped_paragraphs) diff --git a/src/mailman/utilities/tests/test_passwords.py b/src/mailman/utilities/tests/test_passwords.py new file mode 100644 index 000000000..60c201a5a --- /dev/null +++ b/src/mailman/utilities/tests/test_passwords.py @@ -0,0 +1,150 @@ +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Unit tests for the passwords module.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import unittest + +from mailman.core import errors +from mailman.utilities import passwords + + + +class TestPasswordsBase(unittest.TestCase): + scheme = None + + def setUp(self): + # passwords; 8-bit or unicode strings; ascii or binary + self.pw8a = b'abc' + self.pw8b = b'abc\xc3\xbf' # 'abc\xff' + self.pwub = b'abc\xff' + # bad password; 8-bit or unicode; ascii or binary + self.bad8a = b'xyz' + self.bad8b = b'xyz\xc3\xbf' # 'xyz\xff' + self.badub = b'xyz\xff' + + def test_passwords(self): + unless = self.failUnless + failif = self.failIf + secret = passwords.make_secret(self.pw8a, self.scheme) + unless(passwords.check_response(secret, self.pw8a)) + failif(passwords.check_response(secret, self.bad8a)) + + def test_passwords_with_funky_chars(self): + unless = self.failUnless + failif = self.failIf + secret = passwords.make_secret(self.pw8b, self.scheme) + unless(passwords.check_response(secret, self.pw8b)) + failif(passwords.check_response(secret, self.bad8b)) + + def test_unicode_passwords_with_funky_chars(self): + unless = self.failUnless + failif = self.failIf + secret = passwords.make_secret(self.pwub, self.scheme) + unless(passwords.check_response(secret, self.pwub)) + failif(passwords.check_response(secret, self.badub)) + + + +class TestBogusPasswords(TestPasswordsBase): + scheme = -1 + + def test_passwords(self): + self.assertRaises(errors.BadPasswordSchemeError, + passwords.make_secret, self.pw8a, self.scheme) + + def test_passwords_with_funky_chars(self): + self.assertRaises(errors.BadPasswordSchemeError, + passwords.make_secret, self.pw8b, self.scheme) + + def test_unicode_passwords_with_funky_chars(self): + self.assertRaises(errors.BadPasswordSchemeError, + passwords.make_secret, self.pwub, self.scheme) + + + +class TestNonePasswords(TestPasswordsBase): + scheme = passwords.Schemes.no_scheme + + def test_passwords(self): + failif = self.failIf + secret = passwords.make_secret(self.pw8a, self.scheme) + failif(passwords.check_response(secret, self.pw8a)) + failif(passwords.check_response(secret, self.bad8a)) + + def test_passwords_with_funky_chars(self): + failif = self.failIf + secret = passwords.make_secret(self.pw8b, self.scheme) + failif(passwords.check_response(secret, self.pw8b)) + failif(passwords.check_response(secret, self.bad8b)) + + def test_unicode_passwords_with_funky_chars(self): + failif = self.failIf + secret = passwords.make_secret(self.pwub, self.scheme) + failif(passwords.check_response(secret, self.pwub)) + failif(passwords.check_response(secret, self.badub)) + + + +class TestCleartextPasswords(TestPasswordsBase): + scheme = passwords.Schemes.cleartext + + +class TestSHAPasswords(TestPasswordsBase): + scheme = passwords.Schemes.sha + + +class TestSSHAPasswords(TestPasswordsBase): + scheme = passwords.Schemes.ssha + + +class TestPBKDF2Passwords(TestPasswordsBase): + scheme = passwords.Schemes.pbkdf2 + + + +class TestSchemeLookup(unittest.TestCase): + def test_scheme_name_lookup(self): + unless = self.failUnless + unless(passwords.lookup_scheme('NONE') is passwords.Schemes.no_scheme) + unless(passwords.lookup_scheme('CLEARTEXT') is + passwords.Schemes.cleartext) + unless(passwords.lookup_scheme('SHA') is passwords.Schemes.sha) + unless(passwords.lookup_scheme('SSHA') is passwords.Schemes.ssha) + unless(passwords.lookup_scheme('PBKDF2') is passwords.Schemes.pbkdf2) + unless(passwords.lookup_scheme(' -bogus- ') is None) + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestBogusPasswords)) + suite.addTest(unittest.makeSuite(TestNonePasswords)) + suite.addTest(unittest.makeSuite(TestCleartextPasswords)) + suite.addTest(unittest.makeSuite(TestSHAPasswords)) + suite.addTest(unittest.makeSuite(TestSSHAPasswords)) + suite.addTest(unittest.makeSuite(TestPBKDF2Passwords)) + suite.addTest(unittest.makeSuite(TestSchemeLookup)) + return suite diff --git a/src/mailman/utilities/tests/test_templates.py b/src/mailman/utilities/tests/test_templates.py new file mode 100644 index 000000000..e21b44544 --- /dev/null +++ b/src/mailman/utilities/tests/test_templates.py @@ -0,0 +1,287 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Testing i18n template search and interpolation.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import os +import shutil +import tempfile +import unittest + +from zope.component import getUtility + +from mailman.app.lifecycle import create_list +from mailman.config import config +from mailman.interfaces.languages import ILanguageManager +from mailman.testing.layers import ConfigLayer +from mailman.utilities.i18n import TemplateNotFoundError, _search, find, make + + + +class TestSearchOrder(unittest.TestCase): + """Test internal search order for language templates.""" + + layer = ConfigLayer + + def setUp(self): + self.template_dir = tempfile.mkdtemp() + config.push('no template dir', """\ + [mailman] + default_language: fr + [paths.testing] + template_dir: {0}/t + var_dir: {0}/v + """.format(self.template_dir)) + language_manager = getUtility(ILanguageManager) + language_manager.add('de', 'utf-8', 'German') + language_manager.add('it', 'utf-8', 'Italian') + self.mlist = create_list('l@example.com') + self.mlist.preferred_language = 'de' + + def tearDown(self): + config.pop('no template dir') + shutil.rmtree(self.template_dir) + + def _stripped_search_order(self, template_file, + mailing_list=None, language=None): + raw_search_order = _search(template_file, mailing_list, language) + for path in raw_search_order: + yield path[len(self.template_dir):] + + def test_fully_specified_search_order(self): + search_order = self._stripped_search_order('foo.txt', self.mlist, 'it') + # language argument + self.assertEqual(next(search_order), + '/v/lists/l@example.com/it/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/it/foo.txt') + self.assertEqual(next(search_order), '/t/site/it/foo.txt') + self.assertEqual(next(search_order), '/t/it/foo.txt') + # mlist.preferred_language + self.assertEqual(next(search_order), + '/v/lists/l@example.com/de/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/de/foo.txt') + self.assertEqual(next(search_order), '/t/site/de/foo.txt') + self.assertEqual(next(search_order), '/t/de/foo.txt') + # site's default language + self.assertEqual(next(search_order), + '/v/lists/l@example.com/fr/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/fr/foo.txt') + self.assertEqual(next(search_order), '/t/site/fr/foo.txt') + self.assertEqual(next(search_order), '/t/fr/foo.txt') + # English + self.assertEqual(next(search_order), + '/v/lists/l@example.com/en/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/en/foo.txt') + self.assertEqual(next(search_order), '/t/site/en/foo.txt') + self.assertEqual(next(search_order), '/t/en/foo.txt') + + def test_no_language_argument_search_order(self): + search_order = self._stripped_search_order('foo.txt', self.mlist) + # mlist.preferred_language + self.assertEqual(next(search_order), + '/v/lists/l@example.com/de/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/de/foo.txt') + self.assertEqual(next(search_order), '/t/site/de/foo.txt') + self.assertEqual(next(search_order), '/t/de/foo.txt') + # site's default language + self.assertEqual(next(search_order), + '/v/lists/l@example.com/fr/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/fr/foo.txt') + self.assertEqual(next(search_order), '/t/site/fr/foo.txt') + self.assertEqual(next(search_order), '/t/fr/foo.txt') + # English + self.assertEqual(next(search_order), + '/v/lists/l@example.com/en/foo.txt') + self.assertEqual(next(search_order), '/t/example.com/en/foo.txt') + self.assertEqual(next(search_order), '/t/site/en/foo.txt') + self.assertEqual(next(search_order), '/t/en/foo.txt') + + def test_no_mailing_list_argument_search_order(self): + search_order = self._stripped_search_order('foo.txt', language='it') + # language argument + self.assertEqual(next(search_order), '/t/site/it/foo.txt') + self.assertEqual(next(search_order), '/t/it/foo.txt') + # site's default language + self.assertEqual(next(search_order), '/t/site/fr/foo.txt') + self.assertEqual(next(search_order), '/t/fr/foo.txt') + # English + self.assertEqual(next(search_order), '/t/site/en/foo.txt') + self.assertEqual(next(search_order), '/t/en/foo.txt') + + def test_no_optional_arguments_search_order(self): + search_order = self._stripped_search_order('foo.txt') + # site's default language + self.assertEqual(next(search_order), '/t/site/fr/foo.txt') + self.assertEqual(next(search_order), '/t/fr/foo.txt') + # English + self.assertEqual(next(search_order), '/t/site/en/foo.txt') + self.assertEqual(next(search_order), '/t/en/foo.txt') + + + +class TestFind(unittest.TestCase): + """Test template search.""" + + layer = ConfigLayer + + def setUp(self): + self.template_dir = tempfile.mkdtemp() + config.push('template config', """\ + [paths.testing] + template_dir: {0} + """.format(self.template_dir)) + # The following MUST happen AFTER the push() above since pushing a new + # config also clears out the language manager. + getUtility(ILanguageManager).add('xx', 'utf-8', 'Xlandia') + self.mlist = create_list('test@example.com') + self.mlist.preferred_language = 'xx' + self.fp = None + # Populate global tempdir with a few fake templates. + self.xxdir = os.path.join(self.template_dir, 'xx') + os.mkdir(self.xxdir) + with open(os.path.join(self.xxdir, 'global.txt'), 'w') as fp: + fp.write('Global template') + self.sitedir = os.path.join(self.template_dir, 'site', 'xx') + os.makedirs(self.sitedir) + with open(os.path.join(self.sitedir, 'site.txt'), 'w') as fp: + fp.write('Site template') + self.domaindir = os.path.join(self.template_dir, 'example.com', 'xx') + os.makedirs(self.domaindir) + with open(os.path.join(self.domaindir, 'domain.txt'), 'w') as fp: + fp.write('Domain template') + self.listdir = os.path.join(self.mlist.data_path, 'xx') + os.makedirs(self.listdir) + with open(os.path.join(self.listdir, 'list.txt'), 'w') as fp: + fp.write('List template') + + def tearDown(self): + if self.fp is not None: + self.fp.close() + config.pop('template config') + shutil.rmtree(self.template_dir) + shutil.rmtree(self.listdir) + + def test_find_global_template(self): + filename, self.fp = find('global.txt', language='xx') + self.assertEqual(filename, os.path.join(self.xxdir, 'global.txt')) + self.assertEqual(self.fp.read(), 'Global template') + + def test_find_site_template(self): + filename, self.fp = find('site.txt', language='xx') + self.assertEqual(filename, os.path.join(self.sitedir, 'site.txt')) + self.assertEqual(self.fp.read(), 'Site template') + + def test_find_domain_template(self): + filename, self.fp = find('domain.txt', self.mlist) + self.assertEqual(filename, os.path.join(self.domaindir, 'domain.txt')) + self.assertEqual(self.fp.read(), 'Domain template') + + def test_find_list_template(self): + filename, self.fp = find('list.txt', self.mlist) + self.assertEqual(filename, os.path.join(self.listdir, 'list.txt')) + self.assertEqual(self.fp.read(), 'List template') + + def test_template_not_found(self): + # Python 2.6 compatibility. + try: + find('missing.txt', self.mlist) + except TemplateNotFoundError as error: + self.assertEqual(error.template_file, 'missing.txt') + else: + raise AssertionError('TemplateNotFoundError expected') + + + +class TestMake(unittest.TestCase): + """Test template interpolation.""" + + layer = ConfigLayer + + def setUp(self): + self.template_dir = tempfile.mkdtemp() + config.push('template config', """\ + [paths.testing] + template_dir: {0} + """.format(self.template_dir)) + # The following MUST happen AFTER the push() above since pushing a new + # config also clears out the language manager. + getUtility(ILanguageManager).add('xx', 'utf-8', 'Xlandia') + self.mlist = create_list('test@example.com') + self.mlist.preferred_language = 'xx' + # Populate the template directory with some samples. + self.xxdir = os.path.join(self.template_dir, 'xx') + os.mkdir(self.xxdir) + with open(os.path.join(self.xxdir, 'nosub.txt'), 'w') as fp: + print >> fp, """\ +This is a global template. +It has no substitutions. +It will be wrapped. +""" + with open(os.path.join(self.xxdir, 'subs.txt'), 'w') as fp: + print >> fp, """\ +This is a $kind template. +It has $howmany substitutions. +It will be wrapped. +""" + with open(os.path.join(self.xxdir, 'nowrap.txt'), 'w') as fp: + print >> fp, """\ +This is a $kind template. +It has $howmany substitutions. +It will not be wrapped. +""" + + def tearDown(self): + config.pop('template config') + shutil.rmtree(self.template_dir) + + def test_no_substitutions(self): + self.assertEqual(make('nosub.txt', self.mlist), """\ +This is a global template. It has no substitutions. It will be +wrapped.""") + + def test_substitutions(self): + self.assertEqual(make('subs.txt', self.mlist, + kind='very nice', + howmany='a few'), """\ +This is a very nice template. It has a few substitutions. It will be +wrapped.""") + + def test_substitutions_no_wrap(self): + self.assertEqual(make('nowrap.txt', self.mlist, wrap=False, + kind='very nice', + howmany='a few'), """\ +This is a very nice template. +It has a few substitutions. +It will not be wrapped. +""") + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestSearchOrder)) + suite.addTest(unittest.makeSuite(TestFind)) + suite.addTest(unittest.makeSuite(TestMake)) + return suite diff --git a/src/mailman/utilities/tests/test_wrap.py b/src/mailman/utilities/tests/test_wrap.py new file mode 100644 index 000000000..2fc6c6ccf --- /dev/null +++ b/src/mailman/utilities/tests/test_wrap.py @@ -0,0 +1,151 @@ +# Copyright (C) 2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test text wrapping.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import unittest + +from mailman.utilities.string import wrap + + + +class TestWrap(unittest.TestCase): + """Test text wrapping.""" + + def test_simple_wrap(self): + text = """\ +This is a single +paragraph. It consists +of several sentences +none of +which are +very long. +""" + self.assertEqual(wrap(text), """\ +This is a single paragraph. It consists of several sentences none of +which are very long.""") + + def test_two_paragraphs(self): + text = """\ +This is a single +paragraph. It consists +of several sentences +none of +which are +very long. + +And here is a second paragraph which +also consists +of several sentences. None of +these are very long +either. +""" + self.assertEqual(wrap(text), """\ +This is a single paragraph. It consists of several sentences none of +which are very long. + +And here is a second paragraph which also consists of several +sentences. None of these are very long either.""") + + def test_honor_ws(self): + text = """\ +This is a single +paragraph. It consists +of several sentences +none of +which are +very long. + + This paragraph is + indented so it + won't be filled. + +And here is a second paragraph which +also consists +of several sentences. None of +these are very long +either. +""" + self.assertEqual(wrap(text), """\ +This is a single paragraph. It consists of several sentences none of +which are very long. + + This paragraph is + indented so it + won't be filled. + +And here is a second paragraph which also consists of several +sentences. None of these are very long either.""") + + def test_dont_honor_ws(self): + text = """\ +This is a single +paragraph. It consists +of several sentences +none of +which are +very long. + + This paragraph is + indented but we don't + honor whitespace so it + will be filled. + +And here is a second paragraph which +also consists +of several sentences. None of +these are very long +either. +""" + self.assertEqual(wrap(text, honor_leading_ws=False), """\ +This is a single paragraph. It consists of several sentences none of +which are very long. + + This paragraph is indented but we don't honor whitespace so it + will be filled. + +And here is a second paragraph which also consists of several +sentences. None of these are very long either.""") + + def test_indentation_boundary(self): + text = """\ +This is a single paragraph +that consists of one sentence. + And another one that breaks + because it is indented. +Followed by one more paragraph. +""" + self.assertEqual(wrap(text), """\ +This is a single paragraph that consists of one sentence. + And another one that breaks + because it is indented. +Followed by one more paragraph.""") + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestWrap)) + return suite |
