blob: 08899c5cb73a300d340ba2f84e25e82b249d3f55 [file] [log] [blame]
Olivier Deprezf4ef2d02021-04-20 13:36:24 +02001#! /usr/bin/env python3
2
3"""Conversions to/from quoted-printable transport encoding as per RFC 1521."""
4
5# (Dec 1991 version).
6
7__all__ = ["encode", "decode", "encodestring", "decodestring"]
8
9ESCAPE = b'='
10MAXLINESIZE = 76
11HEX = b'0123456789ABCDEF'
12EMPTYSTRING = b''
13
14try:
15 from binascii import a2b_qp, b2a_qp
16except ImportError:
17 a2b_qp = None
18 b2a_qp = None
19
20
21def needsquoting(c, quotetabs, header):
22 """Decide whether a particular byte ordinal needs to be quoted.
23
24 The 'quotetabs' flag indicates whether embedded tabs and spaces should be
25 quoted. Note that line-ending tabs and spaces are always encoded, as per
26 RFC 1521.
27 """
28 assert isinstance(c, bytes)
29 if c in b' \t':
30 return quotetabs
31 # if header, we have to escape _ because _ is used to escape space
32 if c == b'_':
33 return header
34 return c == ESCAPE or not (b' ' <= c <= b'~')
35
36def quote(c):
37 """Quote a single character."""
38 assert isinstance(c, bytes) and len(c)==1
39 c = ord(c)
40 return ESCAPE + bytes((HEX[c//16], HEX[c%16]))
41
42
43
44def encode(input, output, quotetabs, header=False):
45 """Read 'input', apply quoted-printable encoding, and write to 'output'.
46
47 'input' and 'output' are binary file objects. The 'quotetabs' flag
48 indicates whether embedded tabs and spaces should be quoted. Note that
49 line-ending tabs and spaces are always encoded, as per RFC 1521.
50 The 'header' flag indicates whether we are encoding spaces as _ as per RFC
51 1522."""
52
53 if b2a_qp is not None:
54 data = input.read()
55 odata = b2a_qp(data, quotetabs=quotetabs, header=header)
56 output.write(odata)
57 return
58
59 def write(s, output=output, lineEnd=b'\n'):
60 # RFC 1521 requires that the line ending in a space or tab must have
61 # that trailing character encoded.
62 if s and s[-1:] in b' \t':
63 output.write(s[:-1] + quote(s[-1:]) + lineEnd)
64 elif s == b'.':
65 output.write(quote(s) + lineEnd)
66 else:
67 output.write(s + lineEnd)
68
69 prevline = None
70 while 1:
71 line = input.readline()
72 if not line:
73 break
74 outline = []
75 # Strip off any readline induced trailing newline
76 stripped = b''
77 if line[-1:] == b'\n':
78 line = line[:-1]
79 stripped = b'\n'
80 # Calculate the un-length-limited encoded line
81 for c in line:
82 c = bytes((c,))
83 if needsquoting(c, quotetabs, header):
84 c = quote(c)
85 if header and c == b' ':
86 outline.append(b'_')
87 else:
88 outline.append(c)
89 # First, write out the previous line
90 if prevline is not None:
91 write(prevline)
92 # Now see if we need any soft line breaks because of RFC-imposed
93 # length limitations. Then do the thisline->prevline dance.
94 thisline = EMPTYSTRING.join(outline)
95 while len(thisline) > MAXLINESIZE:
96 # Don't forget to include the soft line break `=' sign in the
97 # length calculation!
98 write(thisline[:MAXLINESIZE-1], lineEnd=b'=\n')
99 thisline = thisline[MAXLINESIZE-1:]
100 # Write out the current line
101 prevline = thisline
102 # Write out the last line, without a trailing newline
103 if prevline is not None:
104 write(prevline, lineEnd=stripped)
105
106def encodestring(s, quotetabs=False, header=False):
107 if b2a_qp is not None:
108 return b2a_qp(s, quotetabs=quotetabs, header=header)
109 from io import BytesIO
110 infp = BytesIO(s)
111 outfp = BytesIO()
112 encode(infp, outfp, quotetabs, header)
113 return outfp.getvalue()
114
115
116
117def decode(input, output, header=False):
118 """Read 'input', apply quoted-printable decoding, and write to 'output'.
119 'input' and 'output' are binary file objects.
120 If 'header' is true, decode underscore as space (per RFC 1522)."""
121
122 if a2b_qp is not None:
123 data = input.read()
124 odata = a2b_qp(data, header=header)
125 output.write(odata)
126 return
127
128 new = b''
129 while 1:
130 line = input.readline()
131 if not line: break
132 i, n = 0, len(line)
133 if n > 0 and line[n-1:n] == b'\n':
134 partial = 0; n = n-1
135 # Strip trailing whitespace
136 while n > 0 and line[n-1:n] in b" \t\r":
137 n = n-1
138 else:
139 partial = 1
140 while i < n:
141 c = line[i:i+1]
142 if c == b'_' and header:
143 new = new + b' '; i = i+1
144 elif c != ESCAPE:
145 new = new + c; i = i+1
146 elif i+1 == n and not partial:
147 partial = 1; break
148 elif i+1 < n and line[i+1:i+2] == ESCAPE:
149 new = new + ESCAPE; i = i+2
150 elif i+2 < n and ishex(line[i+1:i+2]) and ishex(line[i+2:i+3]):
151 new = new + bytes((unhex(line[i+1:i+3]),)); i = i+3
152 else: # Bad escape sequence -- leave it in
153 new = new + c; i = i+1
154 if not partial:
155 output.write(new + b'\n')
156 new = b''
157 if new:
158 output.write(new)
159
160def decodestring(s, header=False):
161 if a2b_qp is not None:
162 return a2b_qp(s, header=header)
163 from io import BytesIO
164 infp = BytesIO(s)
165 outfp = BytesIO()
166 decode(infp, outfp, header=header)
167 return outfp.getvalue()
168
169
170
171# Other helper functions
172def ishex(c):
173 """Return true if the byte ordinal 'c' is a hexadecimal digit in ASCII."""
174 assert isinstance(c, bytes)
175 return b'0' <= c <= b'9' or b'a' <= c <= b'f' or b'A' <= c <= b'F'
176
177def unhex(s):
178 """Get the integer value of a hexadecimal number."""
179 bits = 0
180 for c in s:
181 c = bytes((c,))
182 if b'0' <= c <= b'9':
183 i = ord('0')
184 elif b'a' <= c <= b'f':
185 i = ord('a')-10
186 elif b'A' <= c <= b'F':
187 i = ord(b'A')-10
188 else:
189 assert False, "non-hex digit "+repr(c)
190 bits = bits*16 + (ord(c) - i)
191 return bits
192
193
194
195def main():
196 import sys
197 import getopt
198 try:
199 opts, args = getopt.getopt(sys.argv[1:], 'td')
200 except getopt.error as msg:
201 sys.stdout = sys.stderr
202 print(msg)
203 print("usage: quopri [-t | -d] [file] ...")
204 print("-t: quote tabs")
205 print("-d: decode; default encode")
206 sys.exit(2)
207 deco = False
208 tabs = False
209 for o, a in opts:
210 if o == '-t': tabs = True
211 if o == '-d': deco = True
212 if tabs and deco:
213 sys.stdout = sys.stderr
214 print("-t and -d are mutually exclusive")
215 sys.exit(2)
216 if not args: args = ['-']
217 sts = 0
218 for file in args:
219 if file == '-':
220 fp = sys.stdin.buffer
221 else:
222 try:
223 fp = open(file, "rb")
224 except OSError as msg:
225 sys.stderr.write("%s: can't open (%s)\n" % (file, msg))
226 sts = 1
227 continue
228 try:
229 if deco:
230 decode(fp, sys.stdout.buffer)
231 else:
232 encode(fp, sys.stdout.buffer, tabs)
233 finally:
234 if file != '-':
235 fp.close()
236 if sts:
237 sys.exit(sts)
238
239
240
241if __name__ == '__main__':
242 main()