1 #! /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
9 ESCAPE = b'='
10 MAXLINESIZE = 76
11 HEX = b'0123456789ABCDEF'
12 EMPTYSTRING = b''
13
14 try:
15 from binascii import a2b_qp, b2a_qp
16 except ImportError:
17 a2b_qp = None
18 b2a_qp = None
19
20
21 def 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
36 def 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
44 def 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 line := input.readline():
71 outline = []
72 # Strip off any readline induced trailing newline
73 stripped = b''
74 if line[-1:] == b'\n':
75 line = line[:-1]
76 stripped = b'\n'
77 # Calculate the un-length-limited encoded line
78 for c in line:
79 c = bytes((c,))
80 if needsquoting(c, quotetabs, header):
81 c = quote(c)
82 if header and c == b' ':
83 outline.append(b'_')
84 else:
85 outline.append(c)
86 # First, write out the previous line
87 if prevline is not None:
88 write(prevline)
89 # Now see if we need any soft line breaks because of RFC-imposed
90 # length limitations. Then do the thisline->prevline dance.
91 thisline = EMPTYSTRING.join(outline)
92 while len(thisline) > MAXLINESIZE:
93 # Don't forget to include the soft line break `=' sign in the
94 # length calculation!
95 write(thisline[:MAXLINESIZE-1], lineEnd=b'=\n')
96 thisline = thisline[MAXLINESIZE-1:]
97 # Write out the current line
98 prevline = thisline
99 # Write out the last line, without a trailing newline
100 if prevline is not None:
101 write(prevline, lineEnd=stripped)
102
103 def encodestring(s, quotetabs=False, header=False):
104 if b2a_qp is not None:
105 return b2a_qp(s, quotetabs=quotetabs, header=header)
106 from io import BytesIO
107 infp = BytesIO(s)
108 outfp = BytesIO()
109 encode(infp, outfp, quotetabs, header)
110 return outfp.getvalue()
111
112
113
114 def decode(input, output, header=False):
115 """Read 'input', apply quoted-printable decoding, and write to 'output'.
116 'input' and 'output' are binary file objects.
117 If 'header' is true, decode underscore as space (per RFC 1522)."""
118
119 if a2b_qp is not None:
120 data = input.read()
121 odata = a2b_qp(data, header=header)
122 output.write(odata)
123 return
124
125 new = b''
126 while line := input.readline():
127 i, n = 0, len(line)
128 if n > 0 and line[n-1:n] == b'\n':
129 partial = 0; n = n-1
130 # Strip trailing whitespace
131 while n > 0 and line[n-1:n] in b" \t\r":
132 n = n-1
133 else:
134 partial = 1
135 while i < n:
136 c = line[i:i+1]
137 if c == b'_' and header:
138 new = new + b' '; i = i+1
139 elif c != ESCAPE:
140 new = new + c; i = i+1
141 elif i+1 == n and not partial:
142 partial = 1; break
143 elif i+1 < n and line[i+1:i+2] == ESCAPE:
144 new = new + ESCAPE; i = i+2
145 elif i+2 < n and ishex(line[i+1:i+2]) and ishex(line[i+2:i+3]):
146 new = new + bytes((unhex(line[i+1:i+3]),)); i = i+3
147 else: # Bad escape sequence -- leave it in
148 new = new + c; i = i+1
149 if not partial:
150 output.write(new + b'\n')
151 new = b''
152 if new:
153 output.write(new)
154
155 def decodestring(s, header=False):
156 if a2b_qp is not None:
157 return a2b_qp(s, header=header)
158 from io import BytesIO
159 infp = BytesIO(s)
160 outfp = BytesIO()
161 decode(infp, outfp, header=header)
162 return outfp.getvalue()
163
164
165
166 # Other helper functions
167 def ishex(c):
168 """Return true if the byte ordinal 'c' is a hexadecimal digit in ASCII."""
169 assert isinstance(c, bytes)
170 return b'0' <= c <= b'9' or b'a' <= c <= b'f' or b'A' <= c <= b'F'
171
172 def unhex(s):
173 """Get the integer value of a hexadecimal number."""
174 bits = 0
175 for c in s:
176 c = bytes((c,))
177 if b'0' <= c <= b'9':
178 i = ord('0')
179 elif b'a' <= c <= b'f':
180 i = ord('a')-10
181 elif b'A' <= c <= b'F':
182 i = ord(b'A')-10
183 else:
184 assert False, "non-hex digit "+repr(c)
185 bits = bits*16 + (ord(c) - i)
186 return bits
187
188
189
190 def main():
191 import sys
192 import getopt
193 try:
194 opts, args = getopt.getopt(sys.argv[1:], 'td')
195 except getopt.error as msg:
196 sys.stdout = sys.stderr
197 print(msg)
198 print("usage: quopri [-t | -d] [file] ...")
199 print("-t: quote tabs")
200 print("-d: decode; default encode")
201 sys.exit(2)
202 deco = False
203 tabs = False
204 for o, a in opts:
205 if o == '-t': tabs = True
206 if o == '-d': deco = True
207 if tabs and deco:
208 sys.stdout = sys.stderr
209 print("-t and -d are mutually exclusive")
210 sys.exit(2)
211 if not args: args = ['-']
212 sts = 0
213 for file in args:
214 if file == '-':
215 fp = sys.stdin.buffer
216 else:
217 try:
218 fp = open(file, "rb")
219 except OSError as msg:
220 sys.stderr.write("%s: can't open (%s)\n" % (file, msg))
221 sts = 1
222 continue
223 try:
224 if deco:
225 decode(fp, sys.stdout.buffer)
226 else:
227 encode(fp, sys.stdout.buffer, tabs)
228 finally:
229 if file != '-':
230 fp.close()
231 if sts:
232 sys.exit(sts)
233
234
235
236 if __name__ == '__main__':
237 main()