summaryrefslogtreecommitdiff
path: root/src/mailman/utilities
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/utilities')
-rw-r--r--src/mailman/utilities/i18n.py196
-rw-r--r--src/mailman/utilities/passwords.py317
-rw-r--r--src/mailman/utilities/string.py172
-rw-r--r--src/mailman/utilities/tests/test_passwords.py150
-rw-r--r--src/mailman/utilities/tests/test_templates.py287
-rw-r--r--src/mailman/utilities/tests/test_wrap.py151
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