1import cgi 2import os 3import sys 4import tempfile 5import unittest 6from collections import namedtuple 7from io import StringIO, BytesIO 8from test import support 9from test.support import warnings_helper 10 11class HackedSysModule: 12 # The regression test will have real values in sys.argv, which 13 # will completely confuse the test of the cgi module 14 argv = [] 15 stdin = sys.stdin 16 17cgi.sys = HackedSysModule() 18 19class ComparableException: 20 def __init__(self, err): 21 self.err = err 22 23 def __str__(self): 24 return str(self.err) 25 26 def __eq__(self, anExc): 27 if not isinstance(anExc, Exception): 28 return NotImplemented 29 return (self.err.__class__ == anExc.__class__ and 30 self.err.args == anExc.args) 31 32 def __getattr__(self, attr): 33 return getattr(self.err, attr) 34 35def do_test(buf, method): 36 env = {} 37 if method == "GET": 38 fp = None 39 env['REQUEST_METHOD'] = 'GET' 40 env['QUERY_STRING'] = buf 41 elif method == "POST": 42 fp = BytesIO(buf.encode('latin-1')) # FieldStorage expects bytes 43 env['REQUEST_METHOD'] = 'POST' 44 env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded' 45 env['CONTENT_LENGTH'] = str(len(buf)) 46 else: 47 raise ValueError("unknown method: %s" % method) 48 try: 49 return cgi.parse(fp, env, strict_parsing=1) 50 except Exception as err: 51 return ComparableException(err) 52 53parse_strict_test_cases = [ 54 ("", ValueError("bad query field: ''")), 55 ("&", ValueError("bad query field: ''")), 56 ("&&", ValueError("bad query field: ''")), 57 # Should the next few really be valid? 58 ("=", {}), 59 ("=&=", {}), 60 # This rest seem to make sense 61 ("=a", {'': ['a']}), 62 ("&=a", ValueError("bad query field: ''")), 63 ("=a&", ValueError("bad query field: ''")), 64 ("=&a", ValueError("bad query field: 'a'")), 65 ("b=a", {'b': ['a']}), 66 ("b+=a", {'b ': ['a']}), 67 ("a=b=a", {'a': ['b=a']}), 68 ("a=+b=a", {'a': [' b=a']}), 69 ("&b=a", ValueError("bad query field: ''")), 70 ("b&=a", ValueError("bad query field: 'b'")), 71 ("a=a+b&b=b+c", {'a': ['a b'], 'b': ['b c']}), 72 ("a=a+b&a=b+a", {'a': ['a b', 'b a']}), 73 ("x=1&y=2.0&z=2-3.%2b0", {'x': ['1'], 'y': ['2.0'], 'z': ['2-3.+0']}), 74 ("Hbc5161168c542333633315dee1182227:key_store_seqid=400006&cuyer=r&view=bustomer&order_id=0bb2e248638833d48cb7fed300000f1b&expire=964546263&lobale=en-US&kid=130003.300038&ss=env", 75 {'Hbc5161168c542333633315dee1182227:key_store_seqid': ['400006'], 76 'cuyer': ['r'], 77 'expire': ['964546263'], 78 'kid': ['130003.300038'], 79 'lobale': ['en-US'], 80 'order_id': ['0bb2e248638833d48cb7fed300000f1b'], 81 'ss': ['env'], 82 'view': ['bustomer'], 83 }), 84 85 ("group_id=5470&set=custom&_assigned_to=31392&_status=1&_category=100&SUBMIT=Browse", 86 {'SUBMIT': ['Browse'], 87 '_assigned_to': ['31392'], 88 '_category': ['100'], 89 '_status': ['1'], 90 'group_id': ['5470'], 91 'set': ['custom'], 92 }) 93 ] 94 95def norm(seq): 96 return sorted(seq, key=repr) 97 98def first_elts(list): 99 return [p[0] for p in list] 100 101def first_second_elts(list): 102 return [(p[0], p[1][0]) for p in list] 103 104def gen_result(data, environ): 105 encoding = 'latin-1' 106 fake_stdin = BytesIO(data.encode(encoding)) 107 fake_stdin.seek(0) 108 form = cgi.FieldStorage(fp=fake_stdin, environ=environ, encoding=encoding) 109 110 result = {} 111 for k, v in dict(form).items(): 112 result[k] = isinstance(v, list) and form.getlist(k) or v.value 113 114 return result 115 116class CgiTests(unittest.TestCase): 117 118 def test_parse_multipart(self): 119 fp = BytesIO(POSTDATA.encode('latin1')) 120 env = {'boundary': BOUNDARY.encode('latin1'), 121 'CONTENT-LENGTH': '558'} 122 result = cgi.parse_multipart(fp, env) 123 expected = {'submit': [' Add '], 'id': ['1234'], 124 'file': [b'Testing 123.\n'], 'title': ['']} 125 self.assertEqual(result, expected) 126 127 def test_parse_multipart_without_content_length(self): 128 POSTDATA = '''--JfISa01 129Content-Disposition: form-data; name="submit-name" 130 131just a string 132 133--JfISa01-- 134''' 135 fp = BytesIO(POSTDATA.encode('latin1')) 136 env = {'boundary': 'JfISa01'.encode('latin1')} 137 result = cgi.parse_multipart(fp, env) 138 expected = {'submit-name': ['just a string\n']} 139 self.assertEqual(result, expected) 140 141 def test_parse_multipart_invalid_encoding(self): 142 BOUNDARY = "JfISa01" 143 POSTDATA = """--JfISa01 144Content-Disposition: form-data; name="submit-name" 145Content-Length: 3 146 147\u2603 148--JfISa01""" 149 fp = BytesIO(POSTDATA.encode('utf8')) 150 env = {'boundary': BOUNDARY.encode('latin1'), 151 'CONTENT-LENGTH': str(len(POSTDATA.encode('utf8')))} 152 result = cgi.parse_multipart(fp, env, encoding="ascii", 153 errors="surrogateescape") 154 expected = {'submit-name': ["\udce2\udc98\udc83"]} 155 self.assertEqual(result, expected) 156 self.assertEqual("\u2603".encode('utf8'), 157 result["submit-name"][0].encode('utf8', 'surrogateescape')) 158 159 def test_fieldstorage_properties(self): 160 fs = cgi.FieldStorage() 161 self.assertFalse(fs) 162 self.assertIn("FieldStorage", repr(fs)) 163 self.assertEqual(list(fs), list(fs.keys())) 164 fs.list.append(namedtuple('MockFieldStorage', 'name')('fieldvalue')) 165 self.assertTrue(fs) 166 167 def test_fieldstorage_invalid(self): 168 self.assertRaises(TypeError, cgi.FieldStorage, "not-a-file-obj", 169 environ={"REQUEST_METHOD":"PUT"}) 170 self.assertRaises(TypeError, cgi.FieldStorage, "foo", "bar") 171 fs = cgi.FieldStorage(headers={'content-type':'text/plain'}) 172 self.assertRaises(TypeError, bool, fs) 173 174 def test_strict(self): 175 for orig, expect in parse_strict_test_cases: 176 # Test basic parsing 177 d = do_test(orig, "GET") 178 self.assertEqual(d, expect, "Error parsing %s method GET" % repr(orig)) 179 d = do_test(orig, "POST") 180 self.assertEqual(d, expect, "Error parsing %s method POST" % repr(orig)) 181 182 env = {'QUERY_STRING': orig} 183 fs = cgi.FieldStorage(environ=env) 184 if isinstance(expect, dict): 185 # test dict interface 186 self.assertEqual(len(expect), len(fs)) 187 self.assertCountEqual(expect.keys(), fs.keys()) 188 ##self.assertEqual(norm(expect.values()), norm(fs.values())) 189 ##self.assertEqual(norm(expect.items()), norm(fs.items())) 190 self.assertEqual(fs.getvalue("nonexistent field", "default"), "default") 191 # test individual fields 192 for key in expect.keys(): 193 expect_val = expect[key] 194 self.assertIn(key, fs) 195 if len(expect_val) > 1: 196 self.assertEqual(fs.getvalue(key), expect_val) 197 else: 198 self.assertEqual(fs.getvalue(key), expect_val[0]) 199 200 def test_separator(self): 201 parse_semicolon = [ 202 ("x=1;y=2.0", {'x': ['1'], 'y': ['2.0']}), 203 ("x=1;y=2.0;z=2-3.%2b0", {'x': ['1'], 'y': ['2.0'], 'z': ['2-3.+0']}), 204 (";", ValueError("bad query field: ''")), 205 (";;", ValueError("bad query field: ''")), 206 ("=;a", ValueError("bad query field: 'a'")), 207 (";b=a", ValueError("bad query field: ''")), 208 ("b;=a", ValueError("bad query field: 'b'")), 209 ("a=a+b;b=b+c", {'a': ['a b'], 'b': ['b c']}), 210 ("a=a+b;a=b+a", {'a': ['a b', 'b a']}), 211 ] 212 for orig, expect in parse_semicolon: 213 env = {'QUERY_STRING': orig} 214 fs = cgi.FieldStorage(separator=';', environ=env) 215 if isinstance(expect, dict): 216 for key in expect.keys(): 217 expect_val = expect[key] 218 self.assertIn(key, fs) 219 if len(expect_val) > 1: 220 self.assertEqual(fs.getvalue(key), expect_val) 221 else: 222 self.assertEqual(fs.getvalue(key), expect_val[0]) 223 224 @warnings_helper.ignore_warnings(category=DeprecationWarning) 225 def test_log(self): 226 cgi.log("Testing") 227 228 cgi.logfp = StringIO() 229 cgi.initlog("%s", "Testing initlog 1") 230 cgi.log("%s", "Testing log 2") 231 self.assertEqual(cgi.logfp.getvalue(), "Testing initlog 1\nTesting log 2\n") 232 if os.path.exists(os.devnull): 233 cgi.logfp = None 234 cgi.logfile = os.devnull 235 cgi.initlog("%s", "Testing log 3") 236 self.addCleanup(cgi.closelog) 237 cgi.log("Testing log 4") 238 239 def test_fieldstorage_readline(self): 240 # FieldStorage uses readline, which has the capacity to read all 241 # contents of the input file into memory; we use readline's size argument 242 # to prevent that for files that do not contain any newlines in 243 # non-GET/HEAD requests 244 class TestReadlineFile: 245 def __init__(self, file): 246 self.file = file 247 self.numcalls = 0 248 249 def readline(self, size=None): 250 self.numcalls += 1 251 if size: 252 return self.file.readline(size) 253 else: 254 return self.file.readline() 255 256 def __getattr__(self, name): 257 file = self.__dict__['file'] 258 a = getattr(file, name) 259 if not isinstance(a, int): 260 setattr(self, name, a) 261 return a 262 263 f = TestReadlineFile(tempfile.TemporaryFile("wb+")) 264 self.addCleanup(f.close) 265 f.write(b'x' * 256 * 1024) 266 f.seek(0) 267 env = {'REQUEST_METHOD':'PUT'} 268 fs = cgi.FieldStorage(fp=f, environ=env) 269 self.addCleanup(fs.file.close) 270 # if we're not chunking properly, readline is only called twice 271 # (by read_binary); if we are chunking properly, it will be called 5 times 272 # as long as the chunksize is 1 << 16. 273 self.assertGreater(f.numcalls, 2) 274 f.close() 275 276 def test_fieldstorage_multipart(self): 277 #Test basic FieldStorage multipart parsing 278 env = { 279 'REQUEST_METHOD': 'POST', 280 'CONTENT_TYPE': 'multipart/form-data; boundary={}'.format(BOUNDARY), 281 'CONTENT_LENGTH': '558'} 282 fp = BytesIO(POSTDATA.encode('latin-1')) 283 fs = cgi.FieldStorage(fp, environ=env, encoding="latin-1") 284 self.assertEqual(len(fs.list), 4) 285 expect = [{'name':'id', 'filename':None, 'value':'1234'}, 286 {'name':'title', 'filename':None, 'value':''}, 287 {'name':'file', 'filename':'test.txt', 'value':b'Testing 123.\n'}, 288 {'name':'submit', 'filename':None, 'value':' Add '}] 289 for x in range(len(fs.list)): 290 for k, exp in expect[x].items(): 291 got = getattr(fs.list[x], k) 292 self.assertEqual(got, exp) 293 294 def test_fieldstorage_multipart_leading_whitespace(self): 295 env = { 296 'REQUEST_METHOD': 'POST', 297 'CONTENT_TYPE': 'multipart/form-data; boundary={}'.format(BOUNDARY), 298 'CONTENT_LENGTH': '560'} 299 # Add some leading whitespace to our post data that will cause the 300 # first line to not be the innerboundary. 301 fp = BytesIO(b"\r\n" + POSTDATA.encode('latin-1')) 302 fs = cgi.FieldStorage(fp, environ=env, encoding="latin-1") 303 self.assertEqual(len(fs.list), 4) 304 expect = [{'name':'id', 'filename':None, 'value':'1234'}, 305 {'name':'title', 'filename':None, 'value':''}, 306 {'name':'file', 'filename':'test.txt', 'value':b'Testing 123.\n'}, 307 {'name':'submit', 'filename':None, 'value':' Add '}] 308 for x in range(len(fs.list)): 309 for k, exp in expect[x].items(): 310 got = getattr(fs.list[x], k) 311 self.assertEqual(got, exp) 312 313 def test_fieldstorage_multipart_non_ascii(self): 314 #Test basic FieldStorage multipart parsing 315 env = {'REQUEST_METHOD':'POST', 316 'CONTENT_TYPE': 'multipart/form-data; boundary={}'.format(BOUNDARY), 317 'CONTENT_LENGTH':'558'} 318 for encoding in ['iso-8859-1','utf-8']: 319 fp = BytesIO(POSTDATA_NON_ASCII.encode(encoding)) 320 fs = cgi.FieldStorage(fp, environ=env,encoding=encoding) 321 self.assertEqual(len(fs.list), 1) 322 expect = [{'name':'id', 'filename':None, 'value':'\xe7\xf1\x80'}] 323 for x in range(len(fs.list)): 324 for k, exp in expect[x].items(): 325 got = getattr(fs.list[x], k) 326 self.assertEqual(got, exp) 327 328 def test_fieldstorage_multipart_maxline(self): 329 # Issue #18167 330 maxline = 1 << 16 331 self.maxDiff = None 332 def check(content): 333 data = """---123 334Content-Disposition: form-data; name="upload"; filename="fake.txt" 335Content-Type: text/plain 336 337%s 338---123-- 339""".replace('\n', '\r\n') % content 340 environ = { 341 'CONTENT_LENGTH': str(len(data)), 342 'CONTENT_TYPE': 'multipart/form-data; boundary=-123', 343 'REQUEST_METHOD': 'POST', 344 } 345 self.assertEqual(gen_result(data, environ), 346 {'upload': content.encode('latin1')}) 347 check('x' * (maxline - 1)) 348 check('x' * (maxline - 1) + '\r') 349 check('x' * (maxline - 1) + '\r' + 'y' * (maxline - 1)) 350 351 def test_fieldstorage_multipart_w3c(self): 352 # Test basic FieldStorage multipart parsing (W3C sample) 353 env = { 354 'REQUEST_METHOD': 'POST', 355 'CONTENT_TYPE': 'multipart/form-data; boundary={}'.format(BOUNDARY_W3), 356 'CONTENT_LENGTH': str(len(POSTDATA_W3))} 357 fp = BytesIO(POSTDATA_W3.encode('latin-1')) 358 fs = cgi.FieldStorage(fp, environ=env, encoding="latin-1") 359 self.assertEqual(len(fs.list), 2) 360 self.assertEqual(fs.list[0].name, 'submit-name') 361 self.assertEqual(fs.list[0].value, 'Larry') 362 self.assertEqual(fs.list[1].name, 'files') 363 files = fs.list[1].value 364 self.assertEqual(len(files), 2) 365 expect = [{'name': None, 'filename': 'file1.txt', 'value': b'... contents of file1.txt ...'}, 366 {'name': None, 'filename': 'file2.gif', 'value': b'...contents of file2.gif...'}] 367 for x in range(len(files)): 368 for k, exp in expect[x].items(): 369 got = getattr(files[x], k) 370 self.assertEqual(got, exp) 371 372 def test_fieldstorage_part_content_length(self): 373 BOUNDARY = "JfISa01" 374 POSTDATA = """--JfISa01 375Content-Disposition: form-data; name="submit-name" 376Content-Length: 5 377 378Larry 379--JfISa01""" 380 env = { 381 'REQUEST_METHOD': 'POST', 382 'CONTENT_TYPE': 'multipart/form-data; boundary={}'.format(BOUNDARY), 383 'CONTENT_LENGTH': str(len(POSTDATA))} 384 fp = BytesIO(POSTDATA.encode('latin-1')) 385 fs = cgi.FieldStorage(fp, environ=env, encoding="latin-1") 386 self.assertEqual(len(fs.list), 1) 387 self.assertEqual(fs.list[0].name, 'submit-name') 388 self.assertEqual(fs.list[0].value, 'Larry') 389 390 def test_field_storage_multipart_no_content_length(self): 391 fp = BytesIO(b"""--MyBoundary 392Content-Disposition: form-data; name="my-arg"; filename="foo" 393 394Test 395 396--MyBoundary-- 397""") 398 env = { 399 "REQUEST_METHOD": "POST", 400 "CONTENT_TYPE": "multipart/form-data; boundary=MyBoundary", 401 "wsgi.input": fp, 402 } 403 fields = cgi.FieldStorage(fp, environ=env) 404 405 self.assertEqual(len(fields["my-arg"].file.read()), 5) 406 407 def test_fieldstorage_as_context_manager(self): 408 fp = BytesIO(b'x' * 10) 409 env = {'REQUEST_METHOD': 'PUT'} 410 with cgi.FieldStorage(fp=fp, environ=env) as fs: 411 content = fs.file.read() 412 self.assertFalse(fs.file.closed) 413 self.assertTrue(fs.file.closed) 414 self.assertEqual(content, 'x' * 10) 415 with self.assertRaisesRegex(ValueError, 'I/O operation on closed file'): 416 fs.file.read() 417 418 _qs_result = { 419 'key1': 'value1', 420 'key2': ['value2x', 'value2y'], 421 'key3': 'value3', 422 'key4': 'value4' 423 } 424 def testQSAndUrlEncode(self): 425 data = "key2=value2x&key3=value3&key4=value4" 426 environ = { 427 'CONTENT_LENGTH': str(len(data)), 428 'CONTENT_TYPE': 'application/x-www-form-urlencoded', 429 'QUERY_STRING': 'key1=value1&key2=value2y', 430 'REQUEST_METHOD': 'POST', 431 } 432 v = gen_result(data, environ) 433 self.assertEqual(self._qs_result, v) 434 435 def test_max_num_fields(self): 436 # For application/x-www-form-urlencoded 437 data = '&'.join(['a=a']*11) 438 environ = { 439 'CONTENT_LENGTH': str(len(data)), 440 'CONTENT_TYPE': 'application/x-www-form-urlencoded', 441 'REQUEST_METHOD': 'POST', 442 } 443 444 with self.assertRaises(ValueError): 445 cgi.FieldStorage( 446 fp=BytesIO(data.encode()), 447 environ=environ, 448 max_num_fields=10, 449 ) 450 451 # For multipart/form-data 452 data = """---123 453Content-Disposition: form-data; name="a" 454 4553 456---123 457Content-Type: application/x-www-form-urlencoded 458 459a=4 460---123 461Content-Type: application/x-www-form-urlencoded 462 463a=5 464---123-- 465""" 466 environ = { 467 'CONTENT_LENGTH': str(len(data)), 468 'CONTENT_TYPE': 'multipart/form-data; boundary=-123', 469 'QUERY_STRING': 'a=1&a=2', 470 'REQUEST_METHOD': 'POST', 471 } 472 473 # 2 GET entities 474 # 1 top level POST entities 475 # 1 entity within the second POST entity 476 # 1 entity within the third POST entity 477 with self.assertRaises(ValueError): 478 cgi.FieldStorage( 479 fp=BytesIO(data.encode()), 480 environ=environ, 481 max_num_fields=4, 482 ) 483 cgi.FieldStorage( 484 fp=BytesIO(data.encode()), 485 environ=environ, 486 max_num_fields=5, 487 ) 488 489 def testQSAndFormData(self): 490 data = """---123 491Content-Disposition: form-data; name="key2" 492 493value2y 494---123 495Content-Disposition: form-data; name="key3" 496 497value3 498---123 499Content-Disposition: form-data; name="key4" 500 501value4 502---123-- 503""" 504 environ = { 505 'CONTENT_LENGTH': str(len(data)), 506 'CONTENT_TYPE': 'multipart/form-data; boundary=-123', 507 'QUERY_STRING': 'key1=value1&key2=value2x', 508 'REQUEST_METHOD': 'POST', 509 } 510 v = gen_result(data, environ) 511 self.assertEqual(self._qs_result, v) 512 513 def testQSAndFormDataFile(self): 514 data = """---123 515Content-Disposition: form-data; name="key2" 516 517value2y 518---123 519Content-Disposition: form-data; name="key3" 520 521value3 522---123 523Content-Disposition: form-data; name="key4" 524 525value4 526---123 527Content-Disposition: form-data; name="upload"; filename="fake.txt" 528Content-Type: text/plain 529 530this is the content of the fake file 531 532---123-- 533""" 534 environ = { 535 'CONTENT_LENGTH': str(len(data)), 536 'CONTENT_TYPE': 'multipart/form-data; boundary=-123', 537 'QUERY_STRING': 'key1=value1&key2=value2x', 538 'REQUEST_METHOD': 'POST', 539 } 540 result = self._qs_result.copy() 541 result.update({ 542 'upload': b'this is the content of the fake file\n' 543 }) 544 v = gen_result(data, environ) 545 self.assertEqual(result, v) 546 547 def test_parse_header(self): 548 self.assertEqual( 549 cgi.parse_header("text/plain"), 550 ("text/plain", {})) 551 self.assertEqual( 552 cgi.parse_header("text/vnd.just.made.this.up ; "), 553 ("text/vnd.just.made.this.up", {})) 554 self.assertEqual( 555 cgi.parse_header("text/plain;charset=us-ascii"), 556 ("text/plain", {"charset": "us-ascii"})) 557 self.assertEqual( 558 cgi.parse_header('text/plain ; charset="us-ascii"'), 559 ("text/plain", {"charset": "us-ascii"})) 560 self.assertEqual( 561 cgi.parse_header('text/plain ; charset="us-ascii"; another=opt'), 562 ("text/plain", {"charset": "us-ascii", "another": "opt"})) 563 self.assertEqual( 564 cgi.parse_header('attachment; filename="silly.txt"'), 565 ("attachment", {"filename": "silly.txt"})) 566 self.assertEqual( 567 cgi.parse_header('attachment; filename="strange;name"'), 568 ("attachment", {"filename": "strange;name"})) 569 self.assertEqual( 570 cgi.parse_header('attachment; filename="strange;name";size=123;'), 571 ("attachment", {"filename": "strange;name", "size": "123"})) 572 self.assertEqual( 573 cgi.parse_header('form-data; name="files"; filename="fo\\"o;bar"'), 574 ("form-data", {"name": "files", "filename": 'fo"o;bar'})) 575 576 def test_all(self): 577 not_exported = { 578 "logfile", "logfp", "initlog", "dolog", "nolog", "closelog", "log", 579 "maxlen", "valid_boundary"} 580 support.check__all__(self, cgi, not_exported=not_exported) 581 582 583BOUNDARY = "---------------------------721837373350705526688164684" 584 585POSTDATA = """-----------------------------721837373350705526688164684 586Content-Disposition: form-data; name="id" 587 5881234 589-----------------------------721837373350705526688164684 590Content-Disposition: form-data; name="title" 591 592 593-----------------------------721837373350705526688164684 594Content-Disposition: form-data; name="file"; filename="test.txt" 595Content-Type: text/plain 596 597Testing 123. 598 599-----------------------------721837373350705526688164684 600Content-Disposition: form-data; name="submit" 601 602 Add\x20 603-----------------------------721837373350705526688164684-- 604""" 605 606POSTDATA_NON_ASCII = """-----------------------------721837373350705526688164684 607Content-Disposition: form-data; name="id" 608 609\xe7\xf1\x80 610-----------------------------721837373350705526688164684 611""" 612 613# http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4 614BOUNDARY_W3 = "AaB03x" 615POSTDATA_W3 = """--AaB03x 616Content-Disposition: form-data; name="submit-name" 617 618Larry 619--AaB03x 620Content-Disposition: form-data; name="files" 621Content-Type: multipart/mixed; boundary=BbC04y 622 623--BbC04y 624Content-Disposition: file; filename="file1.txt" 625Content-Type: text/plain 626 627... contents of file1.txt ... 628--BbC04y 629Content-Disposition: file; filename="file2.gif" 630Content-Type: image/gif 631Content-Transfer-Encoding: binary 632 633...contents of file2.gif... 634--BbC04y-- 635--AaB03x-- 636""" 637 638if __name__ == '__main__': 639 unittest.main() 640