Skip to content

Commit 6f44a1b

Browse files
gh-66788: Add the imap4-utf-7 codec
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 d733b10 commit 6f44a1b

5 files changed

Lines changed: 211 additions & 0 deletions

File tree

Doc/library/codecs.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,14 @@ encodings.
13841384
| | | Only ``errors='strict'`` |
13851385
| | | is supported. |
13861386
+--------------------+---------+---------------------------+
1387+
| imap4-utf-7 | | Modified UTF-7 encoding |
1388+
| | | of :rfc:`3501` for IMAP4 |
1389+
| | | mailbox names. Only |
1390+
| | | ``errors='strict'`` is |
1391+
| | | supported. |
1392+
| | | |
1393+
| | | .. versionadded:: next |
1394+
+--------------------+---------+---------------------------+
13871395
| mbcs | ansi, | Windows only: Encode the |
13881396
| | dbcs | operand according to the |
13891397
| | | ANSI codepage (CP_ACP). |

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 ``imap4-utf-7`` 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/imap4_utf_7.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
from base64 import b64encode, b64decode
17+
18+
### Codec APIs
19+
20+
def imap4_utf_7_encode(input, errors='strict'):
21+
if errors != 'strict':
22+
raise UnicodeError(f"Unsupported error handling: {errors}")
23+
res = bytearray()
24+
start = 0 # start of the current run
25+
b64run = False # is input[start:i] a Base64 run?
26+
def flush(end):
27+
if start < end:
28+
if b64run:
29+
b64 = b64encode(input[start:end].encode('utf-16-be'),
30+
altchars=b'+,', padded=False)
31+
res.extend(b'&' + b64 + b'-')
32+
else:
33+
res.extend(input[start:end].encode('ascii'))
34+
for i, ch in enumerate(input):
35+
if ch == '&':
36+
flush(i)
37+
res.extend(b'&-')
38+
start = i + 1
39+
b64run = False
40+
elif ' ' <= ch <= '~': # printable ASCII, represented directly
41+
if b64run:
42+
flush(i)
43+
start = i
44+
b64run = False
45+
else: # everything else is Base64-encoded
46+
if not b64run:
47+
flush(i)
48+
start = i
49+
b64run = True
50+
flush(len(input))
51+
return res.take_bytes(), len(input)
52+
53+
def imap4_utf_7_decode(input, errors='strict'):
54+
if errors != 'strict':
55+
raise UnicodeError(f"Unsupported error handling: {errors}")
56+
input = bytes(input)
57+
res = []
58+
start = 0 # start of the current direct ASCII run
59+
i = 0
60+
n = len(input)
61+
def flush(end):
62+
if start < end:
63+
res.append(input[start:end].decode('ascii'))
64+
while i < n:
65+
c = input[i]
66+
if c == b'&'[0]:
67+
flush(i)
68+
j = input.find(b'-', i + 1)
69+
if j < 0:
70+
raise UnicodeDecodeError('imap4-utf-7', input, i, n,
71+
'unterminated shift sequence')
72+
if j == i + 1: # '&-'
73+
res.append('&')
74+
else:
75+
b64 = input[i + 1:j]
76+
try:
77+
data = b64decode(b64, altchars=b'+,',
78+
validate=True, padded=False)
79+
except binascii.Error:
80+
data = b''
81+
if not data or len(data) % 2:
82+
raise UnicodeDecodeError('imap4-utf-7', input, i, j + 1,
83+
'invalid shift sequence')
84+
res.append(data.decode('utf-16-be'))
85+
i = j + 1
86+
start = i
87+
elif b' '[0] <= c <= b'~'[0]:
88+
i += 1
89+
else:
90+
raise UnicodeDecodeError('imap4-utf-7', input, i, i + 1,
91+
'unexpected byte')
92+
flush(n)
93+
return ''.join(res), len(input)
94+
95+
class Codec(codecs.Codec):
96+
def encode(self, input, errors='strict'):
97+
return imap4_utf_7_encode(input, errors)
98+
def decode(self, input, errors='strict'):
99+
return imap4_utf_7_decode(input, errors)
100+
101+
class IncrementalEncoder(codecs.IncrementalEncoder):
102+
def encode(self, input, final=False):
103+
return imap4_utf_7_encode(input, self.errors)[0]
104+
105+
class IncrementalDecoder(codecs.IncrementalDecoder):
106+
def decode(self, input, final=False):
107+
return imap4_utf_7_decode(input, self.errors)[0]
108+
109+
class StreamWriter(Codec, codecs.StreamWriter):
110+
pass
111+
112+
class StreamReader(Codec, codecs.StreamReader):
113+
pass
114+
115+
### encodings module API
116+
117+
def getregentry():
118+
return codecs.CodecInfo(
119+
name='imap4-utf-7',
120+
encode=Codec().encode,
121+
decode=Codec().decode,
122+
incrementalencoder=IncrementalEncoder,
123+
incrementaldecoder=IncrementalDecoder,
124+
streamwriter=StreamWriter,
125+
streamreader=StreamReader,
126+
)

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+
imap4_utf_7_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+
# RFC 3501 section 5.1.3 example.
1415+
('~peter/mail/台北/日本語',
1416+
b'~peter/mail/&U,BTFw-/&ZeVnLIqe-'),
1417+
# Non-printable ASCII (including TAB) is Base64-encoded, not direct.
1418+
('a\tb', b'a&AAk-b'),
1419+
('\x00', b'&AAA-'),
1420+
('Entw\xfcrfe', b'Entw&APw-rfe'),
1421+
('☃', b'&JgM-'), # snowman
1422+
('\U0001f600', b'&2D3eAA-'), # non-BMP (surrogate pair)
1423+
('Sent &\N{DELETE}', b'Sent &-&AH8-'),
1424+
]
1425+
1426+
class Imap4Utf7Test(unittest.TestCase):
1427+
def test_encode(self):
1428+
for uni, encoded in imap4_utf_7_testcases:
1429+
with self.subTest(uni=uni):
1430+
self.assertEqual(uni.encode('imap4-utf-7'), encoded)
1431+
1432+
def test_decode(self):
1433+
for uni, encoded in imap4_utf_7_testcases:
1434+
with self.subTest(encoded=encoded):
1435+
self.assertEqual(encoded.decode('imap4-utf-7'), uni)
1436+
1437+
def test_decode_invalid(self):
1438+
# position of the first offending byte in each case
1439+
testcases = [
1440+
(b'&AAk', 0), # unterminated shift sequence
1441+
(b'&Jgg', 0), # unterminated shift sequence
1442+
(b'&AB-', 0), # Base64 length not a multiple of a code unit
1443+
(b'&@@@-', 0), # invalid Base64
1444+
(b'&====-', 0), # invalid Base64
1445+
(b'a\x80b', 1), # 8-bit byte outside a shift sequence
1446+
(b'a\x1fb', 1), # control byte outside a shift sequence
1447+
]
1448+
for encoded, start in testcases:
1449+
with self.subTest(encoded=encoded):
1450+
with self.assertRaises(UnicodeDecodeError) as cm:
1451+
encoded.decode('imap4-utf-7')
1452+
self.assertEqual(cm.exception.encoding, 'imap4-utf-7')
1453+
self.assertEqual(cm.exception.start, start)
1454+
1455+
def test_encode_lone_surrogate(self):
1456+
with self.assertRaises(UnicodeEncodeError):
1457+
'\ud800'.encode('imap4-utf-7')
1458+
1459+
def test_only_strict_errors(self):
1460+
with self.assertRaises(UnicodeError):
1461+
'x'.encode('imap4-utf-7', 'replace')
1462+
with self.assertRaises(UnicodeError):
1463+
b'x'.decode('imap4-utf-7', 'ignore')
1464+
1465+
def test_stateless(self):
1466+
# The codec is registered and exposes the standard interface.
1467+
info = codecs.lookup('imap4-utf-7')
1468+
self.assertEqual(info.name, 'imap4-utf-7')
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 ``imap4-utf-7`` 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)