• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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