• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1""" Routines for manipulating RFC2047 encoded words.
2
3This is currently a package-private API, but will be considered for promotion
4to a public API if there is demand.
5
6"""
7
8# An ecoded word looks like this:
9#
10#        =?charset[*lang]?cte?encoded_string?=
11#
12# for more information about charset see the charset module.  Here it is one
13# of the preferred MIME charset names (hopefully; you never know when parsing).
14# cte (Content Transfer Encoding) is either 'q' or 'b' (ignoring case).  In
15# theory other letters could be used for other encodings, but in practice this
16# (almost?) never happens.  There could be a public API for adding entries
17# to the CTE tables, but YAGNI for now.  'q' is Quoted Printable, 'b' is
18# Base64.  The meaning of encoded_string should be obvious.  'lang' is optional
19# as indicated by the brackets (they are not part of the syntax) but is almost
20# never encountered in practice.
21#
22# The general interface for a CTE decoder is that it takes the encoded_string
23# as its argument, and returns a tuple (cte_decoded_string, defects).  The
24# cte_decoded_string is the original binary that was encoded using the
25# specified cte.  'defects' is a list of MessageDefect instances indicating any
26# problems encountered during conversion.  'charset' and 'lang' are the
27# corresponding strings extracted from the EW, case preserved.
28#
29# The general interface for a CTE encoder is that it takes a binary sequence
30# as input and returns the cte_encoded_string, which is an ascii-only string.
31#
32# Each decoder must also supply a length function that takes the binary
33# sequence as its argument and returns the length of the resulting encoded
34# string.
35#
36# The main API functions for the module are decode, which calls the decoder
37# referenced by the cte specifier, and encode, which adds the appropriate
38# RFC 2047 "chrome" to the encoded string, and can optionally automatically
39# select the shortest possible encoding.  See their docstrings below for
40# details.
41
42import re
43import base64
44import binascii
45import functools
46from string import ascii_letters, digits
47from email import errors
48
49__all__ = ['decode_q',
50           'encode_q',
51           'decode_b',
52           'encode_b',
53           'len_q',
54           'len_b',
55           'decode',
56           'encode',
57           ]
58
59#
60# Quoted Printable
61#
62
63# regex based decoder.
64_q_byte_subber = functools.partial(re.compile(br'=([a-fA-F0-9]{2})').sub,
65        lambda m: bytes.fromhex(m.group(1).decode()))
66
67def decode_q(encoded):
68    encoded = encoded.replace(b'_', b' ')
69    return _q_byte_subber(encoded), []
70
71
72# dict mapping bytes to their encoded form
73class _QByteMap(dict):
74
75    safe = b'-!*+/' + ascii_letters.encode('ascii') + digits.encode('ascii')
76
77    def __missing__(self, key):
78        if key in self.safe:
79            self[key] = chr(key)
80        else:
81            self[key] = "={:02X}".format(key)
82        return self[key]
83
84_q_byte_map = _QByteMap()
85
86# In headers spaces are mapped to '_'.
87_q_byte_map[ord(' ')] = '_'
88
89def encode_q(bstring):
90    return ''.join(_q_byte_map[x] for x in bstring)
91
92def len_q(bstring):
93    return sum(len(_q_byte_map[x]) for x in bstring)
94
95
96#
97# Base64
98#
99
100def decode_b(encoded):
101    # First try encoding with validate=True, fixing the padding if needed.
102    # This will succeed only if encoded includes no invalid characters.
103    pad_err = len(encoded) % 4
104    missing_padding = b'==='[:4-pad_err] if pad_err else b''
105    try:
106        return (
107            base64.b64decode(encoded + missing_padding, validate=True),
108            [errors.InvalidBase64PaddingDefect()] if pad_err else [],
109        )
110    except binascii.Error:
111        # Since we had correct padding, this is likely an invalid char error.
112        #
113        # The non-alphabet characters are ignored as far as padding
114        # goes, but we don't know how many there are.  So try without adding
115        # padding to see if it works.
116        try:
117            return (
118                base64.b64decode(encoded, validate=False),
119                [errors.InvalidBase64CharactersDefect()],
120            )
121        except binascii.Error:
122            # Add as much padding as could possibly be necessary (extra padding
123            # is ignored).
124            try:
125                return (
126                    base64.b64decode(encoded + b'==', validate=False),
127                    [errors.InvalidBase64CharactersDefect(),
128                     errors.InvalidBase64PaddingDefect()],
129                )
130            except binascii.Error:
131                # This only happens when the encoded string's length is 1 more
132                # than a multiple of 4, which is invalid.
133                #
134                # bpo-27397: Just return the encoded string since there's no
135                # way to decode.
136                return encoded, [errors.InvalidBase64LengthDefect()]
137
138def encode_b(bstring):
139    return base64.b64encode(bstring).decode('ascii')
140
141def len_b(bstring):
142    groups_of_3, leftover = divmod(len(bstring), 3)
143    # 4 bytes out for each 3 bytes (or nonzero fraction thereof) in.
144    return groups_of_3 * 4 + (4 if leftover else 0)
145
146
147_cte_decoders = {
148    'q': decode_q,
149    'b': decode_b,
150    }
151
152def decode(ew):
153    """Decode encoded word and return (string, charset, lang, defects) tuple.
154
155    An RFC 2047/2243 encoded word has the form:
156
157        =?charset*lang?cte?encoded_string?=
158
159    where '*lang' may be omitted but the other parts may not be.
160
161    This function expects exactly such a string (that is, it does not check the
162    syntax and may raise errors if the string is not well formed), and returns
163    the encoded_string decoded first from its Content Transfer Encoding and
164    then from the resulting bytes into unicode using the specified charset.  If
165    the cte-decoded string does not successfully decode using the specified
166    character set, a defect is added to the defects list and the unknown octets
167    are replaced by the unicode 'unknown' character \\uFDFF.
168
169    The specified charset and language are returned.  The default for language,
170    which is rarely if ever encountered, is the empty string.
171
172    """
173    _, charset, cte, cte_string, _ = ew.split('?')
174    charset, _, lang = charset.partition('*')
175    cte = cte.lower()
176    # Recover the original bytes and do CTE decoding.
177    bstring = cte_string.encode('ascii', 'surrogateescape')
178    bstring, defects = _cte_decoders[cte](bstring)
179    # Turn the CTE decoded bytes into unicode.
180    try:
181        string = bstring.decode(charset)
182    except UnicodeError:
183        defects.append(errors.UndecodableBytesDefect("Encoded word "
184            "contains bytes not decodable using {} charset".format(charset)))
185        string = bstring.decode(charset, 'surrogateescape')
186    except LookupError:
187        string = bstring.decode('ascii', 'surrogateescape')
188        if charset.lower() != 'unknown-8bit':
189            defects.append(errors.CharsetError("Unknown charset {} "
190                "in encoded word; decoded as unknown bytes".format(charset)))
191    return string, charset, lang, defects
192
193
194_cte_encoders = {
195    'q': encode_q,
196    'b': encode_b,
197    }
198
199_cte_encode_length = {
200    'q': len_q,
201    'b': len_b,
202    }
203
204def encode(string, charset='utf-8', encoding=None, lang=''):
205    """Encode string using the CTE encoding that produces the shorter result.
206
207    Produces an RFC 2047/2243 encoded word of the form:
208
209        =?charset*lang?cte?encoded_string?=
210
211    where '*lang' is omitted unless the 'lang' parameter is given a value.
212    Optional argument charset (defaults to utf-8) specifies the charset to use
213    to encode the string to binary before CTE encoding it.  Optional argument
214    'encoding' is the cte specifier for the encoding that should be used ('q'
215    or 'b'); if it is None (the default) the encoding which produces the
216    shortest encoded sequence is used, except that 'q' is preferred if it is up
217    to five characters longer.  Optional argument 'lang' (default '') gives the
218    RFC 2243 language string to specify in the encoded word.
219
220    """
221    if charset == 'unknown-8bit':
222        bstring = string.encode('ascii', 'surrogateescape')
223    else:
224        bstring = string.encode(charset)
225    if encoding is None:
226        qlen = _cte_encode_length['q'](bstring)
227        blen = _cte_encode_length['b'](bstring)
228        # Bias toward q.  5 is arbitrary.
229        encoding = 'q' if qlen - blen < 5 else 'b'
230    encoded = _cte_encoders[encoding](bstring)
231    if lang:
232        lang = '*' + lang
233    return "=?{}{}?{}?{}?=".format(charset, lang, encoding, encoded)
234