Skip to content

Commit 562606d

Browse files
gh-66788: Add the utf-7-imap codec (GH-153149)
Implement the modified UTF-7 encoding used for international IMAP4 mailbox names (RFC 3501, section 5.1.3). It differs from UTF-7: "&" is the shift character ("&-" encodes a literal "&"), "," replaces "/" in the Base64 alphabet, and all non-printable-ASCII characters are Base64-encoded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c11af48 commit 562606d

6 files changed

Lines changed: 219 additions & 0 deletions

File tree

Doc/library/codecs.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,6 +1438,14 @@ encodings.
14381438
| | | code actually uses UTF-8 |
14391439
| | | by default. |
14401440
+--------------------+---------+---------------------------+
1441+
| utf-7-imap | mUTF-7 | Modified UTF-7 encoding |
1442+
| | | of :rfc:`3501` for IMAP4 |
1443+
| | | mailbox names. Only |
1444+
| | | ``errors='strict'`` is |
1445+
| | | supported. |
1446+
| | | |
1447+
| | | .. versionadded:: next |
1448+
+--------------------+---------+---------------------------+
14411449

14421450
.. versionchanged:: 3.8
14431451
"unicode_internal" codec is removed.

Doc/whatsnew/3.16.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ curses
214214
wide-character support.
215215
(Contributed by Serhiy Storchaka in :gh:`133031`.)
216216

217+
encodings
218+
---------
219+
220+
* Add the ``utf-7-imap`` codec, implementing the modified UTF-7 encoding
221+
used for international IMAP4 mailbox names (:rfc:`3501`).
222+
(Contributed by Serhiy Storchaka in :gh:`66788`.)
223+
224+
217225
gzip
218226
----
219227

Lib/encodings/aliases.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,10 @@
600600
'utf7' : 'utf_7',
601601
'unicode_1_1_utf_7' : 'utf_7',
602602

603+
# utf_7_imap codec
604+
'csutf7imap' : 'utf_7_imap',
605+
'mutf_7' : 'utf_7_imap',
606+
603607
# utf_8 codec
604608
'csutf8' : 'utf_8',
605609
'u8' : 'utf_8',

Lib/encodings/utf_7_imap.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
""" Codec for the modified UTF-7 encoding used for IMAP4 mailbox names,
2+
as specified in RFC 3501, section 5.1.3.
3+
4+
It differs from UTF-7 (RFC 2152) as follows:
5+
6+
* "&" (not "+") is the shift character introducing a Base64 sequence,
7+
and "&-" encodes a literal "&";
8+
* "," is used instead of "/" in the modified Base64 alphabet;
9+
* only printable US-ASCII characters (except "&") may be represented
10+
directly, so all other characters, including other controls, are
11+
Base64-encoded.
12+
"""
13+
14+
import binascii
15+
import codecs
16+
17+
# The modified Base64 alphabet of RFC 3501: standard Base64 but with "," in
18+
# place of "/".
19+
_alphabet = binascii.BASE64_ALPHABET[:-1] + b','
20+
21+
### Codec APIs
22+
23+
def utf_7_imap_encode(input, errors='strict'):
24+
if errors != 'strict':
25+
raise UnicodeError(f"Unsupported error handling: {errors}")
26+
res = bytearray()
27+
start = 0 # start of the current run
28+
b64run = False # is input[start:i] a Base64 run?
29+
def flush(end):
30+
if start < end:
31+
if b64run:
32+
b64 = binascii.b2a_base64(input[start:end].encode('utf-16-be'),
33+
alphabet=_alphabet, padded=False,
34+
newline=False)
35+
res.extend(b'&' + b64 + b'-')
36+
else:
37+
res.extend(input[start:end].encode('ascii'))
38+
for i, ch in enumerate(input):
39+
if ch == '&':
40+
flush(i)
41+
res.extend(b'&-')
42+
start = i + 1
43+
b64run = False
44+
elif ' ' <= ch <= '~': # printable ASCII, represented directly
45+
if b64run:
46+
flush(i)
47+
start = i
48+
b64run = False
49+
else: # everything else is Base64-encoded
50+
if not b64run:
51+
flush(i)
52+
start = i
53+
b64run = True
54+
flush(len(input))
55+
return res.take_bytes(), len(input)
56+
57+
def utf_7_imap_decode(input, errors='strict'):
58+
if errors != 'strict':
59+
raise UnicodeError(f"Unsupported error handling: {errors}")
60+
input = bytes(input)
61+
res = []
62+
start = 0 # start of the current direct ASCII run
63+
i = 0
64+
n = len(input)
65+
def flush(end):
66+
if start < end:
67+
res.append(input[start:end].decode('ascii'))
68+
while i < n:
69+
c = input[i]
70+
if c == b'&'[0]:
71+
flush(i)
72+
j = input.find(b'-', i + 1)
73+
if j < 0:
74+
raise UnicodeDecodeError('utf-7-imap', input, i, n,
75+
'unterminated shift sequence')
76+
if j == i + 1: # '&-'
77+
res.append('&')
78+
else:
79+
b64 = input[i + 1:j]
80+
try:
81+
data = binascii.a2b_base64(b64, alphabet=_alphabet,
82+
strict_mode=True, padded=False)
83+
except binascii.Error:
84+
data = b''
85+
if not data or len(data) % 2:
86+
raise UnicodeDecodeError('utf-7-imap', input, i, j + 1,
87+
'invalid shift sequence')
88+
res.append(data.decode('utf-16-be'))
89+
i = j + 1
90+
start = i
91+
elif b' '[0] <= c <= b'~'[0]:
92+
i += 1
93+
else:
94+
raise UnicodeDecodeError('utf-7-imap', input, i, i + 1,
95+
'unexpected byte')
96+
flush(n)
97+
return ''.join(res), len(input)
98+
99+
class Codec(codecs.Codec):
100+
def encode(self, input, errors='strict'):
101+
return utf_7_imap_encode(input, errors)
102+
def decode(self, input, errors='strict'):
103+
return utf_7_imap_decode(input, errors)
104+
105+
class IncrementalEncoder(codecs.IncrementalEncoder):
106+
def encode(self, input, final=False):
107+
return utf_7_imap_encode(input, self.errors)[0]
108+
109+
class IncrementalDecoder(codecs.IncrementalDecoder):
110+
def decode(self, input, final=False):
111+
return utf_7_imap_decode(input, self.errors)[0]
112+
113+
class StreamWriter(Codec, codecs.StreamWriter):
114+
pass
115+
116+
class StreamReader(Codec, codecs.StreamReader):
117+
pass
118+
119+
### encodings module API
120+
121+
def getregentry():
122+
return codecs.CodecInfo(
123+
name='utf-7-imap',
124+
encode=Codec().encode,
125+
decode=Codec().decode,
126+
incrementalencoder=IncrementalEncoder,
127+
incrementaldecoder=IncrementalDecoder,
128+
streamwriter=StreamWriter,
129+
streamreader=StreamReader,
130+
)

Lib/test/test_codecs.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,73 @@ def test_decode_invalid(self):
14011401
self.assertEqual(puny.decode("punycode", errors), expected)
14021402

14031403

1404+
utf_7_imap_testcases = [
1405+
# (unicode, modified UTF-7)
1406+
('', b''),
1407+
('INBOX', b'INBOX'),
1408+
# Printable US-ASCII represents itself.
1409+
('abcABC123 !#$%()*+,-./:;<=>?@[]^_`{|}~', b'abcABC123 !#$%()*+,-./:;<=>?@[]^_`{|}~'),
1410+
# "&" is escaped as "&-".
1411+
('&', b'&-'),
1412+
('&&', b'&-&-'),
1413+
('A&B', b'A&-B'),
1414+
# "+" and "+-" are literal, unlike in UTF-7 where "+" is the shift character.
1415+
('+', b'+'),
1416+
('+-', b'+-'),
1417+
# RFC 3501 section 5.1.3 example.
1418+
('~peter/mail/台北/日本語',
1419+
b'~peter/mail/&U,BTFw-/&ZeVnLIqe-'),
1420+
# Non-printable ASCII (including TAB) is Base64-encoded, not direct.
1421+
('a\tb', b'a&AAk-b'),
1422+
('\x00', b'&AAA-'),
1423+
('Entw\xfcrfe', b'Entw&APw-rfe'),
1424+
('ϰ', b'&A,A-'), # "," in the Base64 alphabet ("&A/A-" is invalid)
1425+
('☃', b'&JgM-'), # snowman
1426+
('\U0001f600', b'&2D3eAA-'), # non-BMP (surrogate pair)
1427+
('Sent &\N{DELETE}', b'Sent &-&AH8-'),
1428+
]
1429+
1430+
class Utf7ImapTest(unittest.TestCase):
1431+
@support.subTests('uni,encoded', utf_7_imap_testcases)
1432+
def test_encode(self, uni, encoded):
1433+
self.assertEqual(uni.encode('utf-7-imap'), encoded)
1434+
1435+
@support.subTests('uni,encoded', utf_7_imap_testcases)
1436+
def test_decode(self, uni, encoded):
1437+
self.assertEqual(encoded.decode('utf-7-imap'), uni)
1438+
1439+
# 'start' is the position of the first offending byte in each case.
1440+
@support.subTests('encoded,start', [
1441+
(b'x&', 1), # "&" just before the end, unterminated
1442+
(b'&AAAA', 0), # unterminated, though the Base64 is valid
1443+
(b'&AB-', 0), # Base64 length not a multiple of a code unit
1444+
(b'&A/A-', 0), # "/" not in the alphabet ("&A,A-" is valid)
1445+
(b'&@@@-', 0), # invalid Base64
1446+
(b'a\x80b', 1), # 8-bit byte outside a shift sequence
1447+
(b'a\x1fb', 1), # control byte outside a shift sequence
1448+
])
1449+
def test_decode_invalid(self, encoded, start):
1450+
with self.assertRaises(UnicodeDecodeError) as cm:
1451+
encoded.decode('utf-7-imap')
1452+
self.assertEqual(cm.exception.encoding, 'utf-7-imap')
1453+
self.assertEqual(cm.exception.start, start)
1454+
1455+
def test_encode_lone_surrogate(self):
1456+
with self.assertRaises(UnicodeEncodeError):
1457+
'\ud800'.encode('utf-7-imap')
1458+
1459+
def test_only_strict_errors(self):
1460+
with self.assertRaises(UnicodeError):
1461+
'x'.encode('utf-7-imap', 'replace')
1462+
with self.assertRaises(UnicodeError):
1463+
b'x'.decode('utf-7-imap', 'ignore')
1464+
1465+
def test_stateless(self):
1466+
# The codec is registered and exposes the standard interface.
1467+
info = codecs.lookup('utf-7-imap')
1468+
self.assertEqual(info.name, 'utf-7-imap')
1469+
1470+
14041471
# From http://www.gnu.org/software/libidn/draft-josefsson-idn-test-vectors.html
14051472
nameprep_tests = [
14061473
# 3.1 Map to nothing.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add the ``utf-7-imap`` codec, implementing the modified UTF-7 encoding
2+
used for international IMAP4 mailbox names (:rfc:`3501`, section 5.1.3).

0 commit comments

Comments
 (0)