scripts/dtrx

1
#!/usr/bin/env python
2
# -*- coding: utf-8 -*-
3
#
4
# dtrx -- Intelligently extract various archive types.
5
# Copyright © 2006-2009 Brett Smith <brettcsmith@brettcsmith.org>
6
# Copyright © 2008 Peter Kelemen <Peter.Kelemen@gmail.com>
7
#
8
# This program is free software; you can redistribute it and/or modify it
9
# under the terms of the GNU General Public License as published by the
10
# Free Software Foundation; either version 3 of the License, or (at your
11
# option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful, but
14
# WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
16
# Public License for more details.
17
#
18
# You should have received a copy of the GNU General Public License along
19
# with this program; if not, see <http://www.gnu.org/licenses/>.
20
 
21
# Python 2.3 string methods: 'rfind', 'rindex', 'rjust', 'rstrip'
22
 
23
import errno
24
import fcntl
25
import logging
26
import mimetypes
27
import optparse
28
import os
29
import re
30
import shutil
31
import signal
32
import stat
33
import string
34
import struct
35
import subprocess
36
import sys
37
import tempfile
38
import termios
39
import textwrap
40
import traceback
41
import urllib
42
 
43
try:
44
    set
45
except NameError:
46
    from sets import Set as set
47
 
48
VERSION = "6.6"
49
VERSION_BANNER = """dtrx version %s
50
Copyright © 2006-2009 Brett Smith <brettcsmith@brettcsmith.org>
51
Copyright © 2008 Peter Kelemen <Peter.Kelemen@gmail.com>
52
 
53
This program is free software; you can redistribute it and/or modify it
54
under the terms of the GNU General Public License as published by the
55
Free Software Foundation; either version 3 of the License, or (at your
56
option) any later version.
57
 
58
This program is distributed in the hope that it will be useful, but
59
WITHOUT ANY WARRANTY; without even the implied warranty of
60
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
61
Public License for more details.""" % (VERSION,)
62
 
63
MATCHING_DIRECTORY = 1
64
ONE_ENTRY_KNOWN = 2
65
BOMB = 3
66
EMPTY = 4
67
ONE_ENTRY_FILE = 'file'
68
ONE_ENTRY_DIRECTORY = 'directory'
69
 
70
ONE_ENTRY_UNKNOWN = [ONE_ENTRY_FILE, ONE_ENTRY_DIRECTORY]
71
 
72
EXTRACT_HERE = 1
73
EXTRACT_WRAP = 2
74
EXTRACT_RENAME = 3
75
 
76
RECURSE_ALWAYS = 1
77
RECURSE_ONCE = 2
78
RECURSE_NOT_NOW = 3
79
RECURSE_NEVER = 4
80
RECURSE_LIST = 5
81
 
82
mimetypes.encodings_map.setdefault('.bz2', 'bzip2')
83
mimetypes.encodings_map.setdefault('.lzma', 'lzma')
84
mimetypes.encodings_map.setdefault('.xz', 'xz')
85
mimetypes.types_map.setdefault('.gem', 'application/x-ruby-gem')
86
 
87
logger = logging.getLogger('dtrx-log')
88
 
89
class FilenameChecker(object):
90
    free_func = os.open
91
    free_args = (os.O_CREAT | os.O_EXCL,)
92
    free_close = os.close
93
 
94
    def __init__(self, original_name):
95
        self.original_name = original_name
96
 
97
    def is_free(self, filename):
98
        try:
99
            result = self.free_func(filename, *self.free_args)
100
        except OSError, error:
101
            if error.errno == errno.EEXIST:
102
                return False
103
            raise
104
        if self.free_close:
105
            self.free_close(result)
106
        return True
107
 
108
    def create(self):
109
        fd, filename = tempfile.mkstemp(prefix=self.original_name + '.',
110
                                        dir='.')
111
        os.close(fd)
112
        return filename
113
 
114
    def check(self):
115
        for suffix in [''] + ['.%s' % (x,) for x in range(1, 10)]:
116
            filename = '%s%s' % (self.original_name, suffix)
117
            if self.is_free(filename):
118
                return filename
119
        return self.create()
120
 
121
 
122
class DirectoryChecker(FilenameChecker):
123
    free_func = os.mkdir
124
    free_args = ()
125
    free_close = None
126
 
127
    def create(self):
128
        return tempfile.mkdtemp(prefix=self.original_name + '.', dir='.')
129
 
130
 
131
class ExtractorError(Exception):
132
    pass
133
 
134
 
135
class ExtractorUnusable(Exception):
136
    pass
137
 
138
 
139
EXTRACTION_ERRORS = (ExtractorError, ExtractorUnusable, OSError, IOError)
140
 
141
class BaseExtractor(object):
142
    decoders = {'bzip2': 'bzcat', 'gzip': 'zcat', 'compress': 'zcat',
143
                'lzma': 'lzcat', 'xz': 'xzcat'}
144
    name_checker = DirectoryChecker
145
 
146
    def __init__(self, filename, encoding):
147
        if encoding and (not self.decoders.has_key(encoding)):
148
            raise ValueError("unrecognized encoding %s" % (encoding,))
149
        self.filename = os.path.realpath(filename)
150
        self.encoding = encoding
151
        self.file_count = 0
152
        self.included_archives = []
153
        self.target = None
154
        self.content_type = None
155
        self.content_name = None
156
        self.pipes = []
157
        self.stderr = tempfile.TemporaryFile()
158
        self.exit_codes = []
159
        try:
160
            self.archive = open(filename, 'r')
161
        except (IOError, OSError), error:
162
            raise ExtractorError("could not open %s: %s" %
163
                                 (filename, error.strerror))
164
        if encoding:
165
            self.pipe([self.decoders[encoding]], "decoding")
166
        self.prepare()
167
 
168
    def pipe(self, command, description="extraction"):
169
        self.pipes.append((command, description))
170
 
171
    def first_bad_exit_code(self):
172
        for index, code in enumerate(self.exit_codes):
173
            if code != 0:
174
                return index
175
        return None
176
 
177
    def add_process(self, processes, command, stdin, stdout):
178
        try:
179
            processes.append(subprocess.Popen(command, stdin=stdin,
180
                                              stdout=stdout,
181
                                              stderr=self.stderr))
182
        except OSError, error:
183
            if error.errno == errno.ENOENT:
184
                raise ExtractorUnusable("could not run %s" % (command[0],))
185
            raise
186
 
187
    def run_pipes(self, final_stdout=None):
188
        if not self.pipes:
189
            return
190
        elif final_stdout is None:
191
            final_stdout = open('/dev/null', 'w')
192
        num_pipes = len(self.pipes)
193
        last_pipe = num_pipes - 1
194
        processes = []
195
        for index, command in enumerate([pipe[0] for pipe in self.pipes]):
196
            if index == 0:
197
                stdin = self.archive
198
            else:
199
                stdin = processes[-1].stdout
200
            if index == last_pipe:
201
                stdout = final_stdout
202
            else:
203
                stdout = subprocess.PIPE
204
            self.add_process(processes, command, stdin, stdout)
205
        self.exit_codes = [pipe.wait() for pipe in processes]
206
        self.archive.close()
207
        for index in range(last_pipe):
208
            processes[index].stdout.close()
209
        self.archive = final_stdout
210
 
211
    def prepare(self):
212
        pass
213
 
214
    def check_included_archives(self):
215
        if (self.content_name is None) or (not self.content_name.endswith('/')):
216
            self.included_root = './'
217
        else:
218
            self.included_root = self.content_name
219
        start_index = len(self.included_root)
220
        for path, dirname, filenames in os.walk(self.included_root):
221
            self.file_count += len(filenames)
222
            path = path[start_index:]
223
            for filename in filenames:
224
                if (ExtractorBuilder.try_by_mimetype(filename) or
225
                    ExtractorBuilder.try_by_extension(filename)):
226
                    self.included_archives.append(os.path.join(path, filename))
227
 
228
    def check_contents(self):
229
        if not self.contents:
230
            self.content_type = EMPTY
231
        elif len(self.contents) == 1:
232
            if self.basename() == self.contents[0]:
233
                self.content_type = MATCHING_DIRECTORY
234
            elif os.path.isdir(self.contents[0]):
235
                self.content_type = ONE_ENTRY_DIRECTORY
236
            else:
237
                self.content_type = ONE_ENTRY_FILE
238
            self.content_name = self.contents[0]
239
            if os.path.isdir(self.contents[0]):
240
                self.content_name += '/'
241
        else:
242
            self.content_type = BOMB
243
        self.check_included_archives()
244
 
245
    def basename(self):
246
        pieces = os.path.basename(self.filename).split('.')
247
        extension = '.' + pieces[-1]
248
        if mimetypes.encodings_map.has_key(extension):
249
            pieces.pop()
250
            extension = '.' + pieces[-1]
251
        if (mimetypes.types_map.has_key(extension) or
252
            mimetypes.common_types.has_key(extension) or
253
            mimetypes.suffix_map.has_key(extension)):
254
            pieces.pop()
255
        return '.'.join(pieces)
256
 
257
    def get_stderr(self):
258
        self.stderr.seek(0, 0)
259
        errors = self.stderr.read(-1)
260
        self.stderr.close()
261
        return errors
262
 
263
    def check_success(self, got_output):
264
        error_index = self.first_bad_exit_code()
265
        if (not got_output) and (error_index is not None):
266
            command = ' '.join(self.pipes[error_index][0])
267
            raise ExtractorError("%s error: '%s' returned status code %s" %
268
                                 (self.pipes[error_index][1], command,
269
                                  self.exit_codes[error_index]))
270
        
271
    def extract_archive(self):
272
        self.pipe(self.extract_pipe)
273
        self.run_pipes()
274
 
275
    def extract(self):
276
        try:
277
            self.target = tempfile.mkdtemp(prefix='.dtrx-', dir='.')
278
        except (OSError, IOError), error:
279
            raise ExtractorError("cannot extract here: %s" % (error.strerror,))
280
        old_path = os.path.realpath(os.curdir)
281
        os.chdir(self.target)
282
        try:
283
            self.archive.seek(0, 0)
284
            self.extract_archive()
285
            self.contents = os.listdir('.')
286
            self.check_contents()
287
            self.check_success(self.content_type != EMPTY)
288
        except EXTRACTION_ERRORS:
289
            self.archive.close()
290
            os.chdir(old_path)
291
            shutil.rmtree(self.target, ignore_errors=True)
292
            raise
293
        self.archive.close()
294
        os.chdir(old_path)
295
 
296
    def get_filenames(self, internal=False):
297
        if not internal:
298
            self.pipe(self.list_pipe, "listing")
299
        processes = []
300
        stdin = self.archive
301
        for command in [pipe[0] for pipe in self.pipes]:
302
            self.add_process(processes, command, stdin, subprocess.PIPE)
303
            stdin = processes[-1].stdout
304
        get_output_line = processes[-1].stdout.readline
305
        while True:
306
            line = get_output_line()
307
            if not line:
308
                break
309
            yield line.rstrip('\n')
310
        self.exit_codes = [pipe.wait() for pipe in processes]
311
        self.archive.close()
312
        for process in processes:
313
            process.stdout.close()
314
        self.check_success(False)
315
    
316
 
317
class CompressionExtractor(BaseExtractor):
318
    file_type = 'compressed file'
319
    name_checker = FilenameChecker
320
 
321
    def basename(self):
322
        pieces = os.path.basename(self.filename).split('.')
323
        extension = '.' + pieces[-1]
324
        if mimetypes.encodings_map.has_key(extension):
325
            pieces.pop()
326
        return '.'.join(pieces)
327
 
328
    def get_filenames(self):
329
        # This code used to just immediately yield the basename, under the
330
        # assumption that that would be the filename.  However, if that
331
        # happens, dtrx -l will report this as a valid result for files with
332
        # compression extensions, even if those files shouldn't actually be
333
        # handled this way.  So, we call out to the file command to do a quick
334
        # check and make sure this actually looks like a compressed file.
335
        if 'compress' not in [match[0] for match in
336
                              ExtractorBuilder.try_by_magic(self.filename)]:
337
            raise ExtractorError("doesn't look like a compressed file")
338
        yield self.basename()
339
 
340
    def extract(self):
341
        self.content_type = ONE_ENTRY_KNOWN
342
        self.content_name = self.basename()
343
        self.contents = None
344
        self.included_root = './'
345
        try:
346
            output_fd, self.target = tempfile.mkstemp(prefix='.dtrx-', dir='.')
347
        except (OSError, IOError), error:
348
            raise ExtractorError("cannot extract here: %s" % (error.strerror,))
349
        self.run_pipes(output_fd)
350
        os.close(output_fd)
351
        try:
352
            self.check_success(os.stat(self.target)[stat.ST_SIZE] > 0)
353
        except EXTRACTION_ERRORS:
354
            os.unlink(self.target)
355
            raise
356
            
357
class TarExtractor(BaseExtractor):
358
    file_type = 'tar file'
359
    extract_pipe = ['tar', '-x']
360
    list_pipe = ['tar', '-t']
361
        
362
        
363
class CpioExtractor(BaseExtractor):
364
    file_type = 'cpio file'
365
    extract_pipe = ['cpio', '-i', '--make-directories', '--quiet',
366
                   '--no-absolute-filenames']
367
    list_pipe = ['cpio', '-t', '--quiet']
368
 
369
 
370
class RPMExtractor(CpioExtractor):
371
    file_type = 'RPM'
372
 
373
    def prepare(self):
374
        self.pipe(['rpm2cpio', '-'], "rpm2cpio")
375
 
376
    def basename(self):
377
        pieces = os.path.basename(self.filename).split('.')
378
        if len(pieces) == 1:
379
            return pieces[0]
380
        elif pieces[-1] != 'rpm':
381
            return BaseExtractor.basename(self)
382
        pieces.pop()
383
        if len(pieces) == 1:
384
            return pieces[0]
385
        elif len(pieces[-1]) < 8:
386
            pieces.pop()
387
        return '.'.join(pieces)
388
 
389
    def check_contents(self):
390
        self.check_included_archives()
391
        self.content_type = BOMB
392
 
393
 
394
class DebExtractor(TarExtractor):
395
    file_type = 'Debian package'
396
    data_re = re.compile(r'^data\.tar\.[a-z0-9]+$')
397
 
398
    def prepare(self):
399
        self.pipe(['ar', 't', self.filename], "finding package data file")
400
        for filename in self.get_filenames(internal=True):
401
            if self.data_re.match(filename):
402
                data_filename = filename
403
                break
404
        else:
405
            raise ExtractorError(".deb contains no data.tar file")
406
        self.archive.seek(0, 0)
407
        self.pipes.pop()
408
        # self.pipes = start_pipes
409
        encoding = mimetypes.guess_type(data_filename)[1]
410
        if not encoding:
411
            raise ExtractorError("data.tar file has unrecognized encoding")
412
        self.pipe(['ar', 'p', self.filename, data_filename],
413
                  "extracting data.tar from .deb")
414
        self.pipe([self.decoders[encoding]], "decoding data.tar")
415
 
416
    def basename(self):
417
        pieces = os.path.basename(self.filename).split('_')
418
        if len(pieces) == 1:
419
            return pieces[0]
420
        last_piece = pieces.pop()
421
        if (len(last_piece) > 10) or (not last_piece.endswith('.deb')):
422
            return BaseExtractor.basename(self)
423
        return '_'.join(pieces)
424
 
425
    def check_contents(self):
426
        self.check_included_archives()
427
        self.content_type = BOMB
428
 
429
 
430
class DebMetadataExtractor(DebExtractor):
431
    def prepare(self):
432
        self.pipe(['ar', 'p', self.filename, 'control.tar.gz'],
433
                  "control.tar.gz extraction")
434
        self.pipe(['zcat'], "control.tar.gz decompression")
435
 
436
 
437
class GemExtractor(TarExtractor):
438
    file_type = 'Ruby gem'
439
 
440
    def prepare(self):
441
        self.pipe(['tar', '-xO', 'data.tar.gz'], "data.tar.gz extraction")
442
        self.pipe(['zcat'], "data.tar.gz decompression")
443
 
444
    def check_contents(self):
445
        self.check_included_archives()
446
        self.content_type = BOMB
447
 
448
 
449
class GemMetadataExtractor(CompressionExtractor):
450
    file_type = 'Ruby gem'
451
 
452
    def prepare(self):
453
        self.pipe(['tar', '-xO', 'metadata.gz'], "metadata.gz extraction")
454
        self.pipe(['zcat'], "metadata.gz decompression")
455
 
456
    def basename(self):
457
        return os.path.basename(self.filename) + '-metadata.txt'
458
 
459
 
460
class NoPipeExtractor(BaseExtractor):
461
    # Some extraction tools won't accept the archive from stdin.  With
462
    # these, the piping infrastructure we normally set up generally doesn't
463
    # work, at least at first.  We can still use most of it; we just don't
464
    # want to seed self.archive with the archive file, since that sucks up
465
    # memory.  So instead we seed it with /dev/null, and specify the
466
    # filename on the command line as necessary.  We also open the actual
467
    # file with os.open, to make sure we can actually do it (permissions
468
    # are good, etc.).  This class doesn't do anything by itself; it's just
469
    # meant to be a base class for extractors that rely on these dumb
470
    # tools.
471
    def __init__(self, filename, encoding):
472
        os.close(os.open(filename, os.O_RDONLY))
473
        BaseExtractor.__init__(self, '/dev/null', None)
474
        self.filename = os.path.realpath(filename)
475
 
476
    def extract_archive(self):
477
        self.extract_pipe = self.extract_command + [self.filename]
478
        BaseExtractor.extract_archive(self)
479
 
480
    def get_filenames(self):
481
        self.list_pipe = self.list_command + [self.filename]
482
        return BaseExtractor.get_filenames(self)
483
 
484
 
485
class ZipExtractor(NoPipeExtractor):
486
    file_type = 'Zip file'
487
    extract_command = ['unzip', '-q']
488
    list_command = ['zipinfo', '-1']
489
 
490
 
491
class SevenExtractor(NoPipeExtractor):
492
    file_type = '7z file'
493
    extract_command = ['7z', 'x']
494
    list_command = ['7z', 'l']
495
    border_re = re.compile('^[- ]+$')
496
 
497
    def get_filenames(self):
498
        fn_index = None
499
        for line in NoPipeExtractor.get_filenames(self):
500
            if self.border_re.match(line):
501
                if fn_index is not None:
502
                    break
503
                else:
504
                    fn_index = string.rindex(line, ' ') + 1
505
            elif fn_index is not None:
506
                yield line[fn_index:]
507
        self.archive.close()
508
        
509
 
510
class CABExtractor(NoPipeExtractor):
511
    file_type = 'CAB archive'
512
    extract_command = ['cabextract', '-q']
513
    list_command = ['cabextract', '-l']
514
    border_re = re.compile(r'^[-\+]+$')
515
 
516
    def get_filenames(self):
517
        fn_index = None
518
        filenames = NoPipeExtractor.get_filenames(self)
519
        for line in filenames:
520
            if self.border_re.match(line):
521
                break
522
        for line in filenames:
523
            try:
524
                yield line.split(' | ', 2)[2]
525
            except IndexError:
526
                break
527
        self.archive.close()
528
 
529
 
530
class ShieldExtractor(NoPipeExtractor):
531
    file_type = 'InstallShield archive'
532
    extract_command = ['unshield', 'x']
533
    list_command = ['unshield', 'l']
534
    prefix_re = re.compile(r'^\s+\d+\s+')
535
    end_re = re.compile(r'^\s+-+\s+-+\s*$')
536
 
537
    def get_filenames(self):
538
        for line in NoPipeExtractor.get_filenames(self):
539
            if self.end_re.match(line):
540
                break
541
            else:
542
                match = self.prefix_re.match(line)
543
                if match:
544
                    yield line[match.end():]
545
        self.archive.close()
546
 
547
    def basename(self):
548
        result = NoPipeExtractor.basename(self)
549
        if result.endswith('.hdr'):
550
            result = result[:-4]
551
        return result
552
 
553
 
554
class RarExtractor(NoPipeExtractor):
555
    file_type = 'RAR archive'
556
    extract_command = ['unrar', 'x']
557
    list_command = ['unrar', 'l']
558
    border_re = re.compile('^-+$')
559
 
560
    def get_filenames(self):
561
        inside = False
562
        for line in NoPipeExtractor.get_filenames(self):
563
            if self.border_re.match(line):
564
                if inside:
565
                    break
566
                else:
567
                    inside = True
568
            elif inside:
569
                yield line.split(' ')[1]
570
        self.archive.close()
571
 
572
 
573
class BaseHandler(object):
574
    def __init__(self, extractor, options):
575
        self.extractor = extractor
576
        self.options = options
577
        self.target = None
578
 
579
    def handle(self):
580
        command = 'find'
581
        status = subprocess.call(['find', self.extractor.target, '-type', 'd',
582
                                  '-exec', 'chmod', 'u+rwx', '{}', ';'])
583
        if status == 0:
584
            command = 'chmod'
585
            status = subprocess.call(['chmod', '-R', 'u+rwX',
586
                                      self.extractor.target])
587
        if status != 0:
588
            return "%s returned with exit status %s" % (command, status)
589
        return self.organize()
590
 
591
    def set_target(self, target, checker):
592
        self.target = checker(target).check()
593
        if self.target != target:
594
            logger.warning("extracting %s to %s" %
595
                           (self.extractor.filename, self.target))
596
 
597
 
598
# The "where to extract" table, with options and archive types.
599
# This dictates the contents of each can_handle method.
600
#
601
#         Flat           Overwrite            None
602
# File    basename       basename             FilenameChecked
603
# Match   .              .                    tempdir + checked
604
# Bomb    .              basename             DirectoryChecked
605
 
606
class FlatHandler(BaseHandler):
607
    def can_handle(contents, options):
608
        return ((options.flat and (contents != ONE_ENTRY_KNOWN)) or
609
                (options.overwrite and (contents == MATCHING_DIRECTORY)))
610
    can_handle = staticmethod(can_handle)
611
 
612
    def organize(self):
613
        self.target = '.'
614
        for curdir, dirs, filenames in os.walk(self.extractor.target,
615
                                               topdown=False):
616
            path_parts = curdir.split(os.sep)
617
            if path_parts[0] == '.':
618
                del path_parts[1]
619
            else:
620
                del path_parts[0]
621
            newdir = os.path.join(*path_parts)
622
            if not os.path.isdir(newdir):
623
                os.makedirs(newdir)
624
            for filename in filenames:
625
                os.rename(os.path.join(curdir, filename),
626
                          os.path.join(newdir, filename))
627
            os.rmdir(curdir)
628
 
629
 
630
class OverwriteHandler(BaseHandler):
631
    def can_handle(contents, options):
632
        return ((options.flat and (contents == ONE_ENTRY_KNOWN)) or
633
                (options.overwrite and (contents != MATCHING_DIRECTORY)))
634
    can_handle = staticmethod(can_handle)
635
 
636
    def organize(self):
637
        self.target = self.extractor.basename()
638
        if os.path.isdir(self.target):
639
            shutil.rmtree(self.target)
640
        os.rename(self.extractor.target, self.target)
641
        
642
 
643
class MatchHandler(BaseHandler):
644
    def can_handle(contents, options):
645
        return ((contents == MATCHING_DIRECTORY) or
646
                ((contents in ONE_ENTRY_UNKNOWN) and
647
                 options.one_entry_policy.ok_for_match()))
648
    can_handle = staticmethod(can_handle)
649
 
650
    def organize(self):
651
        source = os.path.join(self.extractor.target,
652
                              os.listdir(self.extractor.target)[0])
653
        if os.path.isdir(source):
654
            checker = DirectoryChecker
655
        else:
656
            checker = FilenameChecker
657
        if self.options.one_entry_policy == EXTRACT_HERE:
658
            destination = self.extractor.content_name.rstrip('/')
659
        else:
660
            destination = self.extractor.basename()
661
        self.set_target(destination, checker)
662
        if os.path.isdir(self.extractor.target):
663
            os.rename(source, self.target)
664
            os.rmdir(self.extractor.target)
665
        else:
666
            os.rename(self.extractor.target, self.target)
667
        self.extractor.included_root = './'
668
 
669
 
670
class EmptyHandler(object):
671
    target = ''
672
 
673
    def can_handle(contents, options):
674
        return contents == EMPTY
675
    can_handle = staticmethod(can_handle)
676
 
677
    def __init__(self, extractor, options): pass
678
    def handle(self): pass
679
 
680
 
681
class BombHandler(BaseHandler):
682
    def can_handle(contents, options):
683
        return True
684
    can_handle = staticmethod(can_handle)
685
 
686
    def organize(self):
687
        basename = self.extractor.basename()
688
        self.set_target(basename, self.extractor.name_checker)
689
        os.rename(self.extractor.target, self.target)
690
 
691
        
692
class BasePolicy(object):
693
    try:
694
        size = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ,
695
                           struct.pack("HHHH", 0, 0, 0, 0))
696
        width = struct.unpack("HHHH", size)[1]
697
    except IOError:
698
        width = 80
699
    width = width - 1
700
    choice_wrapper = textwrap.TextWrapper(width=width, initial_indent=' * ',
701
                                          subsequent_indent='   ',
702
                                          break_long_words=False)
703
    
704
    def __init__(self, options):
705
        self.current_policy = None
706
        if options.batch:
707
            self.permanent_policy = self.answers['']
708
        else:
709
            self.permanent_policy = None
710
 
711
    def ask_question(self, question):
712
        question = question + ["You can:"]
713
        for choice in self.choices:
714
            question.extend(self.choice_wrapper.wrap(choice))
715
        while True:
716
            print "\n".join(question)
717
            try:
718
                answer = raw_input(self.prompt)
719
            except EOFError:
720
                return self.answers['']
721
            try:
722
                return self.answers[answer.lower()]
723
            except KeyError:
724
                print
725
 
726
    def wrap(self, question, *args):
727
        words = question.split()
728
        for arg in args:
729
            words[words.index('%s')] = arg
730
        result = [words.pop(0)]
731
        for word in words:
732
            extend = '%s %s' % (result[-1], word)
733
            if len(extend) > self.width:
734
                result.append(word)
735
            else:
736
                result[-1] = extend
737
        return result
738
 
739
    def __cmp__(self, other):
740
        return cmp(self.current_policy, other)
741
    
742
 
743
class OneEntryPolicy(BasePolicy):
744
    answers = {'h': EXTRACT_HERE, 'i': EXTRACT_WRAP, 'r': EXTRACT_RENAME,
745
               '': EXTRACT_WRAP}
746
    choice_template = ["extract the %s _I_nside a new directory named %s",
747
                       "extract the %s and _R_ename it %s",
748
                       "extract the %s _H_ere"]
749
    prompt = "What do you want to do?  (I/r/h) "
750
 
751
    def __init__(self, options):
752
        BasePolicy.__init__(self, options)
753
        if options.flat:
754
            default = 'h'
755
        elif options.one_entry_default is not None:
756
            default = options.one_entry_default.lower()
757
        else:
758
            return
759
        if 'here'.startswith(default):
760
            self.permanent_policy = EXTRACT_HERE
761
        elif 'rename'.startswith(default):
762
            self.permanent_policy = EXTRACT_RENAME
763
        elif 'inside'.startswith(default):
764
            self.permanent_policy = EXTRACT_WRAP
765
        elif default is not None:
766
            raise ValueError("bad value %s for default policy" % (default,))
767
 
768
    def prep(self, archive_filename, extractor):
769
        question = self.wrap(
770
            "%s contains one %s but its name doesn't match.",
771
            archive_filename, extractor.content_type)
772
        question.append(" Expected: " + extractor.basename())
773
        question.append("   Actual: " + extractor.content_name)
774
        choice_vars = (extractor.content_type, extractor.basename())
775
        self.choices = [text % choice_vars[:text.count('%s')]
776
                        for text in self.choice_template]
777
        self.current_policy = (self.permanent_policy or
778
                               self.ask_question(question))
779
 
780
    def ok_for_match(self):
781
        return self.current_policy in (EXTRACT_RENAME, EXTRACT_HERE)
782
 
783
 
784
class RecursionPolicy(BasePolicy):
785
    answers = {'o': RECURSE_ONCE, 'a': RECURSE_ALWAYS, 'n': RECURSE_NOT_NOW,
786
               'v': RECURSE_NEVER, 'l': RECURSE_LIST, '': RECURSE_NOT_NOW}
787
    choices = ["_A_lways extract included archives during this session",
788
               "extract included archives this _O_nce",
789
               "choose _N_ot to extract included archives this once",
790
               "ne_V_er extract included archives during this session",
791
               "_L_ist included archives"]
792
    prompt = "What do you want to do?  (a/o/N/v/l) "
793
 
794
    def __init__(self, options):
795
        BasePolicy.__init__(self, options)
796
        if options.show_list:
797
            self.permanent_policy = RECURSE_NEVER
798
        elif options.recursive:
799
            self.permanent_policy = RECURSE_ALWAYS
800
 
801
    def prep(self, current_filename, target, extractor):
802
        archive_count = len(extractor.included_archives)
803
        if (self.permanent_policy is not None) or (archive_count == 0):
804
            self.current_policy = self.permanent_policy or RECURSE_NOT_NOW
805
            return
806
        question = self.wrap(
807
            "%s contains %s other archive file(s), out of %s file(s) total.",
808
            current_filename, archive_count, extractor.file_count)
809
        if target == '.':
810
            target = ''
811
        included_root = extractor.included_root
812
        if included_root == './':
813
            included_root = ''
814
        while True:
815
            self.current_policy = self.ask_question(question)
816
            if self.current_policy != RECURSE_LIST:
817
                break
818
            print ("\n%s\n" %
819
                   '\n'.join([os.path.join(target, included_root, filename)
820
                              for filename in extractor.included_archives]))
821
        if self.current_policy in (RECURSE_ALWAYS, RECURSE_NEVER):
822
            self.permanent_policy = self.current_policy
823
 
824
    def ok_to_recurse(self):
825
        return self.current_policy in (RECURSE_ALWAYS, RECURSE_ONCE)
826
            
827
 
828
class ExtractorBuilder(object):
829
    extractor_map = {'tar': {'extractor': TarExtractor,
830
                             'mimetypes': ('x-tar',),
831
                             'extensions': ('tar',),
832
                             'magic': ('POSIX tar archive',)},
833
                     'zip': {'extractor': ZipExtractor,
834
                             'mimetypes': ('zip',),
835
                             'extensions': ('zip',),
836
                             'magic': ('(Zip|ZIP self-extracting) archive',)},
837
                     'rpm': {'extractor': RPMExtractor,
838
                             'mimetypes': ('x-redhat-package-manager', 'x-rpm'),
839
                             'extensions': ('rpm',),
840
                             'magic': ('RPM',)},
841
                     'deb': {'extractor': DebExtractor,
842
                             'metadata': DebMetadataExtractor,
843
                             'mimetypes': ('x-debian-package',),
844
                             'extensions': ('deb',),
845
                             'magic': ('Debian binary package',)},
846
                     'cpio': {'extractor': CpioExtractor,
847
                              'mimetypes': ('x-cpio',),
848
                              'extensions': ('cpio',),
849
                              'magic': ('cpio archive',)},
850
                     'gem': {'extractor': GemExtractor,
851
                             'metadata': GemMetadataExtractor,
852
                             'mimetypes': ('x-ruby-gem',),
853
                             'extensions': ('gem',)},
854
                     '7z': {'extractor': SevenExtractor,
855
                             'mimetypes': ('x-7z-compressed',),
856
                             'extensions': ('7z',),
857
                             'magic': ('7-zip archive',)},
858
                     'cab': {'extractor': CABExtractor,
859
                             'mimetypes': ('x-cab',),
860
                             'extensions': ('cab',),
861
                             'magic': ('Microsoft Cabinet Archive',)},
862
                     'rar': {'extractor': RarExtractor,
863
                             'mimetypes': ('rar',),
864
                             'extensions': ('rar',),
865
                             'magic': ('RAR archive',)},
866
                     'shield': {'extractor': ShieldExtractor,
867
                                'mimetypes': ('x-cab',),
868
                                'extensions': ('cab', 'hdr'),
869
                                'magic': ('InstallShield CAB',)},
870
                     'compress': {'extractor': CompressionExtractor}
871
                     }
872
 
873
    mimetype_map = {}
874
    magic_mime_map = {}
875
    extension_map = {}
876
    for ext_name, ext_info in extractor_map.items():
877
        for mimetype in ext_info.get('mimetypes', ()):
878
            if '/' not in mimetype:
879
                mimetype = 'application/' + mimetype
880
            mimetype_map[mimetype] = ext_name
881
        for magic_re in ext_info.get('magic', ()):
882
            magic_mime_map[re.compile(magic_re)] = ext_name
883
        for extension in ext_info.get('extensions', ()):
884
            extension_map.setdefault(extension, []).append((ext_name, None))
885
 
886
    for mapping in (('tar', 'bzip2', 'tar.bz2', 'tbz2', 'tb2', 'tbz'),
887
                    ('tar', 'gzip', 'tar.gz', 'tgz'),
888
                    ('tar', 'lzma', 'tar.lzma', 'tlz'),
889
                    ('tar', 'xz', 'tar.xz'),
890
                    ('tar', 'compress', 'tar.Z', 'taz'),
891
                    ('compress', 'gzip', 'Z', 'gz'),
892
                    ('compress', 'bzip2', 'bz2'),
893
                    ('compress', 'lzma', 'lzma'),
894
                    ('compress', 'xz', 'xz')):
895
        for extension in mapping[2:]:
896
            extension_map.setdefault(extension, []).append(mapping[:2])
897
 
898
    magic_encoding_map = {}
899
    for mapping in (('bzip2', 'bzip2 compressed'),
900
                    ('gzip', 'gzip compressed'),
901
                    ('lzma', 'LZMA compressed'),
902
                    ('xz', 'xz compressed')):
903
        for pattern in mapping[1:]:
904
            magic_encoding_map[re.compile(pattern)] = mapping[0]
905
 
906
    def __init__(self, filename, options):
907
        self.filename = filename
908
        self.options = options
909
 
910
    def build_extractor(self, archive_type, encoding):
911
        extractors = self.extractor_map[archive_type]
912
        if self.options.metadata and extractors.has_key('metadata'):
913
            extractor = extractors['metadata']
914
        else:
915
            extractor = extractors['extractor']
916
        return extractor(self.filename, encoding)
917
 
918
    def get_extractor(self):
919
        tried_types = set()
920
        # As smart as it is, the magic test can't go first, because at least
921
        # on my system it just recognizes gem files as tar files.  I guess
922
        # it's possible for the opposite problem to occur -- where the mimetype
923
        # or extension suggests something less than ideal -- but it seems less
924
        # likely so I'm sticking with this.
925
        for func_name in ('mimetype', 'extension', 'magic'):
926
            logger.debug("getting extractors by %s" % (func_name,))
927
            extractor_types = \
928
                            getattr(self, 'try_by_' + func_name)(self.filename)
929
            logger.debug("done getting extractors")
930
            for ext_args in extractor_types:
931
                if ext_args in tried_types:
932
                    continue
933
                tried_types.add(ext_args)
934
                logger.debug("trying %s extractor from %s" %
935
                             (ext_args, func_name))
936
                yield self.build_extractor(*ext_args)
937
 
938
    def try_by_mimetype(cls, filename):
939
        mimetype, encoding = mimetypes.guess_type(filename)
940
        try:
941
            return [(cls.mimetype_map[mimetype], encoding)]
942
        except KeyError:
943
            if encoding:
944
                return [('compress', encoding)]
945
        return []
946
    try_by_mimetype = classmethod(try_by_mimetype)
947
 
948
    def magic_map_matches(cls, output, magic_map):
949
        return [result for regexp, result in magic_map.items()
950
                if regexp.search(output)]
951
    magic_map_matches = classmethod(magic_map_matches)
952
        
953
    def try_by_magic(cls, filename):
954
        process = subprocess.Popen(['file', '-z', filename],
955
                                   stdout=subprocess.PIPE)
956
        status = process.wait()
957
        if status != 0:
958
            return []
959
        output = process.stdout.readline()
960
        process.stdout.close()
961
        if output.startswith('%s: ' % filename):
962
            output = output[len(filename) + 2:]
963
        mimes = cls.magic_map_matches(output, cls.magic_mime_map)
964
        encodings = cls.magic_map_matches(output, cls.magic_encoding_map)
965
        if mimes and not encodings:
966
            encodings = [None]
967
        elif encodings and not mimes:
968
            mimes = ['compress']
969
        return [(m, e) for m in mimes for e in encodings]
970
    try_by_magic = classmethod(try_by_magic)
971
 
972
    def try_by_extension(cls, filename):
973
        parts = filename.split('.')[-2:]
974
        results = []
975
        while parts:
976
            results.extend(cls.extension_map.get('.'.join(parts), []))
977
            del parts[0]
978
        return results
979
    try_by_extension = classmethod(try_by_extension)
980
 
981
 
982
class BaseAction(object):
983
    def __init__(self, options, filenames):
984
        self.options = options
985
        self.filenames = filenames
986
        self.target = None
987
        self.do_print = False
988
        
989
    def report(self, function, *args):
990
        try:
991
            error = function(*args)
992
        except EXTRACTION_ERRORS, exception:
993
            error = str(exception)
994
            logger.debug(''.join(traceback.format_exception(*sys.exc_info())))
995
        return error
996
 
997
    def show_filename(self, filename):
998
        if len(self.filenames) < 2:
999
            return
1000
        elif self.do_print:
1001
            print
1002
        else:
1003
            self.do_print = True
1004
        print "%s:" % (filename,)
1005
 
1006
 
1007
class ExtractionAction(BaseAction):
1008
    handlers = [FlatHandler, OverwriteHandler, MatchHandler, EmptyHandler,
1009
                BombHandler]
1010
 
1011
    def get_handler(self, extractor):
1012
        if extractor.content_type in ONE_ENTRY_UNKNOWN:
1013
            self.options.one_entry_policy.prep(self.current_filename,
1014
                                               extractor)
1015
        for handler in self.handlers:
1016
            if handler.can_handle(extractor.content_type, self.options):
1017
                logger.debug("using %s handler" % (handler.__name__,))
1018
                self.current_handler = handler(extractor, self.options)
1019
                break
1020
 
1021
    def show_extraction(self, extractor):
1022
        if self.options.log_level > logging.INFO:
1023
            return
1024
        self.show_filename(self.current_filename)
1025
        if extractor.contents is None:
1026
            print self.current_handler.target
1027
            return
1028
        def reverser(x, y):
1029
            return cmp(y, x)
1030
        if self.current_handler.target == '.':
1031
            filenames = extractor.contents
1032
            filenames.sort(reverser)
1033
        else:
1034
            filenames = [self.current_handler.target]
1035
        pathjoin = os.path.join
1036
        isdir = os.path.isdir
1037
        while filenames:
1038
            filename = filenames.pop()
1039
            if isdir(filename):
1040
                print "%s/" % (filename,)
1041
                new_filenames = os.listdir(filename)
1042
                new_filenames.sort(reverser)
1043
                filenames.extend([pathjoin(filename, new_filename)
1044
                                  for new_filename in new_filenames])
1045
            else:
1046
                print filename
1047
 
1048
    def run(self, filename, extractor):
1049
        self.current_filename = filename
1050
        error = (self.report(extractor.extract) or
1051
                 self.report(self.get_handler, extractor) or
1052
                 self.report(self.current_handler.handle) or
1053
                 self.report(self.show_extraction, extractor))
1054
        if not error:
1055
            self.target = self.current_handler.target
1056
        return error
1057
 
1058
 
1059
class ListAction(BaseAction):
1060
    def list_filenames(self, extractor, filename):
1061
        # We get a line first to make sure there's not going to be some
1062
        # basic error before we show what filename we're listing.
1063
        filename_lister = extractor.get_filenames()
1064
        try:
1065
            first_line = filename_lister.next()
1066
        except StopIteration:
1067
            self.show_filename(filename)
1068
        else:
1069
            self.did_list = True
1070
            self.show_filename(filename)
1071
            print first_line
1072
        for line in filename_lister:
1073
            print line
1074
            
1075
    def run(self, filename, extractor):
1076
        self.did_list = False
1077
        error = self.report(self.list_filenames, extractor, filename)
1078
        if error and self.did_list:
1079
            logger.error("lister failed: ignore above listing for %s" %
1080
                         (filename,))
1081
        return error
1082
 
1083
class UrlHandler(urllib.FancyURLopener):
1084
    def http_error_default(self, url, fp, errcode, errmsg, headers):
1085
        urllib.URLopener.http_error_default(self, url, fp, errcode, errmsg, headers)
1086
    
1087
    def is_url(self, url):
1088
        if url.startswith("http://"):
1089
	    return True
1090
 
1091
    def fetch(self, url):
1092
        i = url.rfind('/')
1093
        filename = url[i+1:]
1094
        try:
1095
            self.retrieve(url, filename)
1096
        except IOError:
1097
        	return False, "Failed to fetch "+url
1098
        return True, filename
1099
 
1100
 
1101
class ExtractorApplication(object):
1102
    def __init__(self, arguments):
1103
        for signal_num in (signal.SIGINT, signal.SIGTERM):
1104
            signal.signal(signal_num, self.abort)
1105
        signal.signal(signal.SIGPIPE, signal.SIG_DFL)
1106
        self.parse_options(arguments)
1107
        self.setup_logger()
1108
        self.successes = []
1109
        self.failures = []
1110
 
1111
    def clean_destination(self, dest_name):
1112
        try:
1113
            os.unlink(dest_name)
1114
        except OSError, error:
1115
            if error.errno == errno.EISDIR:
1116
                shutil.rmtree(dest_name, ignore_errors=True)
1117
 
1118
    def abort(self, signal_num, frame):
1119
        signal.signal(signal_num, signal.SIG_IGN)
1120
        print
1121
        logger.debug("traceback:\n" +
1122
                     ''.join(traceback.format_stack(frame)).rstrip())
1123
        logger.debug("got signal %s" % (signal_num,))
1124
        try:
1125
            basename = self.current_extractor.target
1126
        except AttributeError:
1127
            basename = None
1128
        if basename is not None:
1129
            logger.debug("cleaning up %s" % (basename,))
1130
            clean_targets = set([os.path.realpath('.')])
1131
            if hasattr(self, 'current_directory'):
1132
                clean_targets.add(os.path.realpath(self.current_directory))
1133
            for directory in clean_targets:
1134
                self.clean_destination(os.path.join(directory, basename))
1135
        sys.exit(1)
1136
 
1137
    def parse_options(self, arguments):
1138
        parser = optparse.OptionParser(
1139
            usage="%prog [options] archive [archive2 ...]",
1140
            description="Intelligent archive extractor",
1141
            version=VERSION_BANNER
1142
            )
1143
        parser.add_option('-l', '-t', '--list', '--table', dest='show_list',
1144
                          action='store_true', default=False,
1145
                          help="list contents of archives on standard output")
1146
        parser.add_option('-m', '--metadata', dest='metadata',
1147
                          action='store_true', default=False,
1148
                          help="extract metadata from a .deb/.gem")
1149
        parser.add_option('-r', '--recursive', dest='recursive',
1150
                          action='store_true', default=False,
1151
                          help="extract archives contained in the ones listed")
1152
        parser.add_option('--one', '--one-entry', dest='one_entry_default',
1153
                          default=None,
1154
                          help=("specify extraction policy for one-entry " +
1155
                                "archives: inside/rename/here"))
1156
        parser.add_option('-n', '--noninteractive', dest='batch',
1157
                          action='store_true', default=False,
1158
                          help="don't ask how to handle special cases")
1159
        parser.add_option('-o', '--overwrite', dest='overwrite',
1160
                          action='store_true', default=False,
1161
                          help="overwrite any existing target output")
1162
        parser.add_option('-f', '--flat', '--no-directory', dest='flat',
1163
                          action='store_true', default=False,
1164
                          help="extract everything to the current directory")
1165
        parser.add_option('-v', '--verbose', dest='verbose',
1166
                          action='count', default=0,
1167
                          help="be verbose/print debugging information")
1168
        parser.add_option('-q', '--quiet', dest='quiet',
1169
                          action='count', default=3,
1170
                          help="suppress warning/error messages")
1171
        self.options, filenames = parser.parse_args(arguments)
1172
        if not filenames:
1173
            parser.error("you did not list any archives")
1174
        # This makes WARNING is the default.
1175
        self.options.log_level = (10 * (self.options.quiet -
1176
                                        self.options.verbose))
1177
        try:
1178
            self.options.one_entry_policy = OneEntryPolicy(self.options)
1179
        except ValueError:
1180
            parser.error("invalid value for --one-entry option")
1181
        self.options.recursion_policy = RecursionPolicy(self.options)
1182
        self.archives = {os.path.realpath(os.curdir): filenames}
1183
 
1184
    def setup_logger(self):
1185
        logging.getLogger().setLevel(self.options.log_level)
1186
        handler = logging.StreamHandler()
1187
        handler.setLevel(self.options.log_level)
1188
        formatter = logging.Formatter("dtrx: %(levelname)s: %(message)s")
1189
        handler.setFormatter(formatter)
1190
        logger.addHandler(handler)
1191
        logger.debug("logger is set up")
1192
 
1193
    def recurse(self, filename, extractor, action):
1194
        self.options.recursion_policy.prep(filename, action.target, extractor)
1195
        if self.options.recursion_policy.ok_to_recurse():
1196
            for filename in extractor.included_archives:
1197
                logger.debug("recursing with %s archive" %
1198
                             (extractor.content_type,))
1199
                tail_path, basename = os.path.split(filename)
1200
                path_args = [self.current_directory, extractor.included_root,
1201
                             tail_path]
1202
                logger.debug("included root: %s" % (extractor.included_root,))
1203
                logger.debug("tail path: %s" % (tail_path,))
1204
                if os.path.isdir(action.target):
1205
                    logger.debug("action target: %s" % (action.target,))
1206
                    path_args.insert(1, action.target)
1207
                directory = os.path.join(*path_args)
1208
                self.archives.setdefault(directory, []).append(basename)
1209
 
1210
    def check_file(self, filename):
1211
        try:
1212
            result = os.stat(filename)
1213
        except OSError, error:
1214
            return error.strerror
1215
        if stat.S_ISDIR(result.st_mode):
1216
            return "cannot work with a directory"
1217
 
1218
    def show_stderr(self, logger_func, stderr):
1219
        if stderr:
1220
            logger_func("Error output from this process:\n" +
1221
                        stderr.rstrip('\n'))
1222
 
1223
    def try_extractors(self, filename, builder):
1224
        errors = []
1225
        for extractor in builder:
1226
            self.current_extractor = extractor  # For the abort() method.
1227
            error = self.action.run(filename, extractor)
1228
            if error:
1229
                errors.append((extractor.file_type, extractor.encoding, error,
1230
                               extractor.get_stderr()))
1231
                if extractor.target is not None:
1232
                    self.clean_destination(extractor.target)
1233
            else:
1234
                self.show_stderr(logger.warn, extractor.get_stderr())
1235
                self.recurse(filename, extractor, self.action)
1236
                return
1237
        logger.error("could not handle %s" % (filename,))
1238
        if not errors:
1239
            logger.error("not a known archive type")
1240
            return True
1241
        for file_type, encoding, error, stderr in errors:
1242
            message = ["treating as", file_type, "failed:", error]
1243
            if encoding:
1244
                message.insert(1, "%s-encoded" % (encoding,))
1245
            logger.error(' '.join(message))
1246
            self.show_stderr(logger.error, stderr)
1247
        return True
1248
        
1249
    def run(self):
1250
        urlhandler = UrlHandler();
1251
        if self.options.show_list:
1252
            action = ListAction
1253
        else:
1254
            action = ExtractionAction
1255
        self.action = action(self.options, self.archives.values()[0])
1256
        while self.archives:
1257
            self.current_directory, self.filenames = self.archives.popitem()
1258
            os.chdir(self.current_directory)
1259
            for filename in self.filenames:
1260
                if urlhandler.is_url(filename):
1261
                    error, filename = urlhandler.fetch(filename)
1262
                builder = ExtractorBuilder(filename, self.options)
1263
                error = (self.check_file(filename) or
1264
                         self.try_extractors(filename, builder.get_extractor()))
1265
                if error:
1266
                    if error != True:
1267
                        logger.error("%s: %s" % (filename, error))
1268
                    self.failures.append(filename)
1269
                else:
1270
                    self.successes.append(filename)
1271
            self.options.one_entry_policy.permanent_policy = EXTRACT_WRAP
1272
        if self.failures:
1273
            return 1
1274
        return 0
1275
 
1276
 
1277
if __name__ == '__main__':
1278
    app = ExtractorApplication(sys.argv[1:])
1279
    sys.exit(app.run())