allow checking PDTCRYPT archives for IV integrity with crypto.py
[python-delta-tar] / testing / test_recover.py
1 """
2 Intra2net 2017
3
4 ===============================================================================
5                test_recover.py – behavior facing file corruption
6 ===============================================================================
7
8 Corruptors have the signature ``(unittest × string × bool × bool) → void``,
9 where the *string* argument is the name of the file to modify, the *booleans*
10 specialize the operation for compressed and encrypted data. Issues are
11 communicated upward by throwing.
12
13     - corrupt_header ():
14       Modify the first object header where it hurts. With encryption, the tag
15       is corrupted to cause authentication of the decrypted data to fail. For
16       compressed data, the two byte magic is altered, for uncompressed
17       archives, the tar header checksum field.
18
19     - corrupt_truncate ():
20       Drop the file’s content after two thirds, causing extraction of later
21       objects to fail. Since the operation preserves the offsets of objects
22       before the cutoff, this yields the same results regardless of whether
23       restore or rescue mode is used.
24
25     - corrupt_ctsize ():
26       Modify the *ctsize* field of a PDTCRYPT header. The goal is to have
27       decryption continue past the end of the object, causing data
28       authentication to fail and file reads to be at odds with the offsets in
29       the index. Only applicable to encrypted archives; will raise
30       *UndefinedTest* otherwise.
31
32     - corrupt_entire_header ():
33       Invert all bits of the first object header (PDTCRYPT, gzip, tar) without
34       affecting the payload. This renders the object unreadable; the result will
35       resemble a file with arbitrary leading data but all the remaining object
36       offsets intact, so the contents can still be extracted with index based
37       recovery.
38
39     - corrupt_payload_start ():
40       For all header variants, skip to the first byte past the header and
41       corrupt it. Encrypted objects will fail to authenticate. Compressed
42       objects will yield a bad CRC32. The Tar layer will take no notice but
43       the extracted object will fail an independent checksum comparison with
44       that of the original file.
45
46     - corrupt_leading_garbage ():
47       Prepend random data to an otherwise valid file. Creates a situation that
48       index based recovery cannot handle by shifting the offsets of all objects
49       in the file. In rescue mode, these objects must be located and extracted
50       regardless.
51
52     - corrupt_trailing_data ():
53       Append data to an otherwise valid file. Both the recovery and rescue
54       modes must be able to retrieve all objects from that file.
55
56     - corrupt_volume ():
57       Zero out an entire backup file. This is interesting for multivolume
58       tests: all files from the affected volume must be missing but objects
59       that span volume bounds will still be partially recoverable.
60
61     - corrupt_hole ():
62       Remove a region from a file. Following the damaged part, no object can be
63       recovered in index mode, but rescue mode will still find those. The
64       object containing the start of the hole will fail checksum tests because
65       of the missing part and the overlap with the subsequent object.
66
67 """
68
69 import logging
70 import os
71 import shutil
72 import stat
73 import sys
74 import unittest
75
76 from functools import partial
77
78 import deltatar.deltatar as deltatar
79 import deltatar.crypto   as crypto
80 import deltatar.tarfile  as tarfile
81
82 from . import BaseTest
83
84 TEST_PASSWORD     = "test1234"
85 TEST_VOLSIZ       = 2 # MB
86 TEST_FILESPERVOL  = 3
87 VOLUME_OVERHEAD   = 1.4 # account for tar overhead when fitting files into
88                         # volumes; this is black magic
89 TEST_BLOCKSIZE    = 4096
90
91 ###############################################################################
92 ## helpers                                                                   ##
93 ###############################################################################
94
95 def flip_bits (fname, off, b=0x01, n=1):
96     """
97     Open file *fname* at offset *off*, replacing the next *n* bytes with
98     their values xor’ed with *b*.
99     """
100     fd = os.open (fname, os.O_RDWR)
101
102     try:
103         pos = os.lseek (fd, off, os.SEEK_SET)
104         assert pos == off
105         chunk = os.read (fd, n)
106         chunk = bytes (map (lambda v: v ^ b, chunk))
107         pos = os.lseek (fd, off, os.SEEK_SET)
108         assert pos == off
109         os.write (fd, chunk)
110     finally:
111         os.close (fd)
112
113
114 def gz_header_size (fname, off=0):
115     """
116     Determine the length of the gzip header starting at *off* in file fname.
117
118     The header is variable length because it may contain the filename as NUL
119     terminated bytes.
120     """
121     # length so we need to determine where the actual payload starts
122     off = tarfile.GZ_HEADER_SIZE
123     fd = os.open (fname, os.O_RDONLY)
124
125     try:
126         pos = os.lseek (fd, off, os.SEEK_SET)
127         assert pos == off
128         while os.read (fd, 1)[0] != 0:
129             off += 1
130             pos = os.lseek (fd, off, os.SEEK_SET)
131             assert pos == off
132     finally:
133         os.close (fd)
134
135     return off
136
137
138 def is_pdt_encrypted (fname):
139     """
140     Returns true if the file contains at least one PDT header plus enough
141     space for the object.
142     """
143     try:
144         with open (fname, "rb") as st:
145             hdr = crypto.hdr_read_stream (st)
146             siz = hdr ["ctsize"]
147             assert (len (st.read (siz)) == siz)
148     except Exception as exn:
149         return False
150     return True
151
152
153 ###############################################################################
154 ## corruption simulators                                                     ##
155 ###############################################################################
156
157 class UndefinedTest (Exception):
158     """No test available for the asked combination of parameters."""
159
160 def corrupt_header (_, fname, compress, encrypt):
161     """
162     Modify a significant byte in the object header of the format.
163     """
164     if encrypt is True: # damage GCM tag
165         flip_bits (fname, crypto.HDR_OFF_TAG + 1)
166     elif compress is True: # invalidate magic
167         flip_bits (fname, 1)
168     else: # Fudge checksum. From tar(5):
169         #
170         #       struct header_gnu_tar {
171         #               char name[100];
172         #               char mode[8];
173         #               char uid[8];
174         #               char gid[8];
175         #               char size[12];
176         #               char mtime[12];
177         #               char checksum[8];
178         #               …
179         flip_bits (fname, 100 + 8 + 8 + 8 + 12 + 12 + 1)
180
181
182 def corrupt_truncate (_, fname, _compress, _encrypt):
183     """
184     Shorten file by one third.
185     """
186     fd   = os.open (fname, os.O_WRONLY)
187     size = os.lseek (fd, 0, os.SEEK_END)
188     os.ftruncate (fd, 2 * size // 3)
189     os.fsync (fd)
190     os.close (fd)
191
192
193 def corrupt_ctsize (_, fname, compress, encrypt):
194     """
195     Blow up the size of an object so as to cause its apparent payload to leak
196     into the next one.
197     """
198     if encrypt is True:
199         # damage lowest bit of second least significant byte of size field;
200         # this effectively sets the ciphertext size to 422, causing it to
201         # extend over the next object into the third one.
202         return flip_bits (fname, crypto.HDR_OFF_CTSIZE + 1, b=0x01)
203     raise UndefinedTest ("corrupt_ctsize %s %s %s" % (fname, compress, encrypt))
204
205
206 def corrupt_entire_header (_, fname, compress, encrypt):
207     """
208     Flip all bits in the first object header.
209     """
210     if encrypt is True:
211         flip_bits (fname, 0, 0xff, crypto.PDTCRYPT_HDR_SIZE)
212     elif compress is True:
213         flip_bits (fname, 0, 0xff, gz_header_size (fname))
214     else:
215         flip_bits (fname, 0, 0xff, tarfile.BLOCKSIZE)
216
217
218 def corrupt_payload_start (_, fname, compress, encrypt):
219     """
220     Modify the byte following the object header structure of the format.
221     """
222     if encrypt is True:
223         flip_bits (fname, crypto.PDTCRYPT_HDR_SIZE + 1)
224     elif compress is True:
225         flip_bits (fname, gz_header_size (fname) + 1)
226     else:
227         flip_bits (fname, tarfile.BLOCKSIZE + 1)
228
229
230 def corrupt_leading_garbage (_, fname, compress, encrypt):
231     """
232     Prepend junk to file.
233     """
234     aname = os.path.abspath (fname)
235     infd  = os.open (fname, os.O_RDONLY)
236     size  = os.lseek (infd, 0, os.SEEK_END)
237     assert os.lseek (infd, 0, os.SEEK_SET) == 0
238     outfd = os.open (os.path.dirname (aname), os.O_WRONLY | os.O_TMPFILE,
239                      stat.S_IRUSR | stat.S_IWUSR)
240     junk  = os.urandom (42)
241
242     # write new file with garbage prepended
243     done = 0
244     os.write (outfd, junk) # junk first
245     done += len (junk)
246     while done < size:
247         data = os.read (infd, TEST_BLOCKSIZE)
248         os.write (outfd, data)
249         done += len (data)
250
251     assert os.lseek (outfd, 0, os.SEEK_CUR) == done
252
253     # close and free old file
254     os.close (infd)
255     os.unlink (fname)
256
257     # install the new file in its place, atomically
258     path = "/proc/self/fd/%d" % outfd
259     os.link (path, aname, src_dir_fd=0, follow_symlinks=True)
260     os.close (outfd)
261
262
263 def corrupt_trailing_data (_, fname, compress, encrypt):
264     """
265     Append random data to file.
266     """
267     junk = os.urandom (42)
268     fd = os.open (fname, os.O_WRONLY | os.O_APPEND)
269     os.write (fd, junk)
270     os.close (fd)
271
272
273 def corrupt_volume (_, fname, compress, encrypt):
274     """
275     Zero out an entire volume.
276     """
277     fd = os.open (fname, os.O_WRONLY)
278     size = os.lseek (fd, 0, os.SEEK_END)
279     assert os.lseek (fd, 0, os.SEEK_SET) == 0
280     zeros = bytes (b'\x00' * TEST_BLOCKSIZE)
281     while size > 0:
282         todo = min (size, TEST_BLOCKSIZE)
283         os.write (fd, zeros [:todo])
284         size -= todo
285     os.close (fd)
286
287
288 def corrupt_hole (_, fname, compress, encrypt):
289     """
290     Cut file in three pieces, reassemble without the middle one.
291     """
292     aname = os.path.abspath (fname)
293     infd = os.open (fname, os.O_RDONLY)
294     size = os.lseek (infd, 0, os.SEEK_END)
295     assert os.lseek (infd, 0, os.SEEK_SET) == 0
296     assert size > 3 * TEST_BLOCKSIZE
297     hole = (size / 3, size * 2 / 3)
298     outfd = os.open (os.path.dirname (aname), os.O_WRONLY | os.O_TMPFILE,
299                      stat.S_IRUSR | stat.S_IWUSR)
300     
301     done = 0
302     while done < size:
303         data = os.read (infd, TEST_BLOCKSIZE)
304         if done < hole [0] or hole [1] < done:
305             # only copy from outside hole
306             os.write (outfd, data)
307         done += len (data)
308
309     os.close (infd)
310     os.unlink (fname)
311
312     path = "/proc/self/fd/%d" % outfd
313     os.link (path, aname, src_dir_fd=0, follow_symlinks=True)
314     os.close (outfd)
315
316 def immaculate (_, _fname, _compress, _encrypt):
317     """
318     No-op dummy.
319     """
320     pass
321
322 ###############################################################################
323 ## tests                                                                     ##
324 ###############################################################################
325
326 class DefectiveTest (BaseTest):
327     """
328     Disaster recovery: restore corrupt backups.
329     """
330
331     COMPRESSION = None
332     PASSWORD    = None
333     FAILURES    = 0     # files that could not be restored
334     MISMATCHES  = 0     # files that were restored but corrupted
335     CORRUPT     = corrupt_payload_start
336     VOLUMES     = 1
337     MISSING     = None  # normally the number of failures
338
339
340     def setUp(self):
341         '''
342         Create base test data
343         '''
344         self.pwd      = os.getcwd()
345         self.dst_path = "source_dir"
346         self.src_path = "%s2" % self.dst_path
347         self.hash     = dict()
348
349         os.system('rm -rf target_dir source_dir* backup_dir* huge')
350         os.makedirs (self.src_path)
351
352         for i in range (5):
353             f = "dummy_%d" % i
354             self.hash [f] = self.create_file ("%s/%s"
355                                               % (self.src_path, f), 5 + i)
356
357
358     def tearDown(self):
359         '''
360         Remove temporal files created by unit tests and reset globals.
361         '''
362         os.chdir(self.pwd)
363         os.system("rm -rf source_dir source_dir2 backup_dir*")
364
365
366     @staticmethod
367     def default_volume_name (backup_file, _x, _y, n, *a, **kwa):
368         return backup_file % n
369
370     def gen_file_names (self, comp, pw):
371         bak_path       = "backup_dir"
372         backup_file    = "the_full_backup_%0.2d.tar"
373         backup_full    = ("%s/%s" % (bak_path, backup_file)) % 0
374         index_file     = "the_full_index"
375
376         if self.COMPRESSION is not None:
377             backup_file += ".gz"
378             backup_full += ".gz"
379             index_file  += ".gz"
380
381         if self.PASSWORD is not None:
382             backup_file = "%s.%s" % (backup_file, deltatar.PDTCRYPT_EXTENSION)
383             backup_full = "%s.%s" % (backup_full, deltatar.PDTCRYPT_EXTENSION)
384             index_file  = "%s.%s" % (index_file , deltatar.PDTCRYPT_EXTENSION)
385
386         return bak_path, backup_file, backup_full, index_file
387
388
389     def gen_multivol (self, nvol):
390         # add n files for one nth the volume size each, corrected
391         # for metadata and tar block overhead
392         fsiz = int (  (  TEST_VOLSIZ
393                        / (TEST_FILESPERVOL * VOLUME_OVERHEAD))
394                     * 1024 * 1024)
395         fcnt = (self.VOLUMES - 1) * TEST_FILESPERVOL
396         for i in range (fcnt):
397             nvol, invol = divmod(i, TEST_FILESPERVOL)
398             f = "dummy_vol_%d_n_%0.2d" % (nvol, invol)
399             self.hash [f] = self.create_file ("%s/%s"
400                                               % (self.src_path, f),
401                                               fsiz,
402                                               random=True)
403
404
405 class RecoverTest (DefectiveTest):
406     """
407     Recover: restore corrupt backups from index file information.
408     """
409
410     def test_recover_corrupt (self):
411         """
412         Perform various damaging actions that cause unreadable objects.
413
414         Expects the extraction to fail in normal mode. With disaster recovery,
415         extraction must succeed, and exactly one file must be missing.
416         """
417         mode = self.COMPRESSION or "#"
418         bak_path, backup_file, backup_full, index_file = \
419             self.gen_file_names (self.COMPRESSION, self.PASSWORD)
420
421         if self.VOLUMES > 1:
422             self.gen_multivol (self.VOLUMES)
423
424         vname = partial (self.default_volume_name, backup_file)
425         dtar = deltatar.DeltaTar (mode=mode,
426                                   logger=None,
427                                   password=self.PASSWORD,
428                                   index_name_func=lambda _: index_file,
429                                   volume_name_func=vname)
430
431         dtar.create_full_backup \
432             (source_path=self.src_path, backup_path=bak_path,
433              max_volume_size=1)
434
435         if self.PASSWORD is not None:
436             # ensure all files are at least superficially in PDT format
437             for f in os.listdir (bak_path):
438                 assert is_pdt_encrypted ("%s/%s" % (bak_path, f))
439
440         # first restore must succeed
441         dtar.restore_backup(target_path=self.dst_path,
442                             backup_indexes_paths=[
443                                 "%s/%s" % (bak_path, index_file)
444                             ],
445                             disaster=tarfile.TOLERANCE_RECOVER,
446                             strict_validation=False)
447         for key, value in self.hash.items ():
448             f = "%s/%s" % (self.dst_path, key)
449             assert os.path.exists (f)
450             assert value == self.md5sum (f)
451         shutil.rmtree (self.dst_path)
452         shutil.rmtree (self.src_path)
453
454         self.CORRUPT (backup_full,
455                       self.COMPRESSION is not None,
456                       self.PASSWORD    is not None)
457
458         # normal restore must fail
459         try:
460             dtar.restore_backup(target_path=self.dst_path,
461                                 backup_tar_path=backup_full)
462         except tarfile.CompressionError:
463             if self.PASSWORD is not None or self.COMPRESSION is not None:
464                 pass
465             else:
466                 raise
467         except tarfile.ReadError:
468             # can happen with all three modes
469             pass
470         except tarfile.DecryptionError:
471             if self.PASSWORD is not None:
472                 pass
473             else:
474                 raise
475
476         os.chdir (self.pwd) # not restored due to the error above
477         # but recover will succeed
478         failed = dtar.recover_backup(target_path=self.dst_path,
479                                      backup_indexes_paths=[
480                                          "%s/%s" % (bak_path, index_file)
481                                      ])
482
483         assert len (failed) == self.FAILURES
484
485         # with one file missing
486         missing  = []
487         mismatch = []
488         for key, value in self.hash.items ():
489             kkey = "%s/%s" % (self.dst_path, key)
490             if os.path.exists (kkey):
491                 if value != self.md5sum (kkey):
492                     mismatch.append (key)
493             else:
494                 missing.append (key)
495
496         # usually, an object whose extraction fails will not be found on
497         # disk afterwards so the number of failures equals that of missing
498         # files. however, some modes will create partial files for objects
499         # spanning multiple volumes that contain the parts whose checksums
500         # were valid.
501         assert len (missing)  == (self.MISSING if self.MISSING is not None
502                                                else self.FAILURES)
503         assert len (mismatch) == self.MISMATCHES
504
505         shutil.rmtree (self.dst_path)
506
507
508 class RescueTest (DefectiveTest):
509     """
510     Rescue: restore corrupt backups from backup set that is damaged to a degree
511     that the index file is worthless.
512     """
513
514     def test_rescue_corrupt (self):
515         """
516         Perform various damaging actions that cause unreadable objects, then
517         attempt to extract objects regardless.
518         """
519         mode = self.COMPRESSION or "#"
520         bak_path, backup_file, backup_full, index_file = \
521             self.gen_file_names (self.COMPRESSION, self.PASSWORD)
522
523         if self.VOLUMES > 1:
524             self.gen_multivol (self.VOLUMES)
525
526         vname = partial (self.default_volume_name, backup_file)
527         dtar = deltatar.DeltaTar (mode=mode,
528                                   logger=None,
529                                   password=self.PASSWORD,
530                                   index_name_func=lambda _: index_file,
531                                   volume_name_func=vname)
532
533         dtar.create_full_backup \
534             (source_path=self.src_path, backup_path=bak_path,
535              max_volume_size=1)
536
537         if self.PASSWORD is not None:
538             # ensure all files are at least superficially in PDT format
539             for f in os.listdir (bak_path):
540                 assert is_pdt_encrypted ("%s/%s" % (bak_path, f))
541
542         # first restore must succeed
543         dtar.restore_backup(target_path=self.dst_path,
544                             backup_indexes_paths=[
545                                 "%s/%s" % (bak_path, index_file)
546                             ],
547                             disaster=tarfile.TOLERANCE_RECOVER,
548                             strict_validation=False)
549         for key, value in self.hash.items ():
550             f = "%s/%s" % (self.dst_path, key)
551             assert os.path.exists (f)
552             assert value == self.md5sum (f)
553         shutil.rmtree (self.dst_path)
554         shutil.rmtree (self.src_path)
555
556         self.CORRUPT (backup_full,
557                       self.COMPRESSION is not None,
558                       self.PASSWORD    is not None)
559
560         # normal restore must fail
561         try:
562             dtar.restore_backup(target_path=self.dst_path,
563                                 backup_tar_path=backup_full)
564         except tarfile.CompressionError:
565             if self.PASSWORD is not None or self.COMPRESSION is not None:
566                 pass
567             else:
568                 raise
569         except tarfile.ReadError:
570             # can happen with all three modes
571             pass
572         except tarfile.DecryptionError:
573             if self.PASSWORD is not None:
574                 pass
575             else:
576                 raise
577
578         os.chdir (self.pwd) # not restored due to the error above
579         # but recover will succeed
580         failed = dtar.rescue_backup(target_path=self.dst_path,
581                                     backup_tar_path=backup_full)
582         # with one file missing
583         missing  = []
584         mismatch = []
585         for key, value in self.hash.items ():
586             kkey = "%s/%s" % (self.dst_path, key)
587             if os.path.exists (kkey):
588                 if value != self.md5sum (kkey):
589                     mismatch.append (key)
590             else:
591                 missing.append (key)
592
593         assert len (failed)   == self.FAILURES
594         assert len (missing)  == (self.MISSING if self.MISSING is not None
595                                                else self.FAILURES)
596         assert len (mismatch) == self.MISMATCHES
597
598         shutil.rmtree (self.dst_path)
599
600
601 class GenIndexTest (DefectiveTest):
602     """
603     Deducing an index for a backup with tarfile.
604     """
605
606     def test_gen_index (self):
607         """
608         Create backup, leave it unharmed, then generate an index.
609         """
610         mode = self.COMPRESSION or "#"
611         bak_path, backup_file, backup_full, index_file = \
612             self.gen_file_names (self.COMPRESSION, self.PASSWORD)
613
614         if self.VOLUMES > 1:
615             self.gen_multivol (self.VOLUMES)
616
617         vname = partial (self.default_volume_name, backup_file)
618         dtar = deltatar.DeltaTar (mode=mode,
619                                   logger=None,
620                                   password=self.PASSWORD,
621                                   index_name_func=lambda _: index_file,
622                                   volume_name_func=vname)
623
624         dtar.create_full_backup \
625             (source_path=self.src_path, backup_path=bak_path,
626              max_volume_size=1)
627
628         def gen_volume_name (nvol):
629             return os.path.join (bak_path, vname (backup_full, True, nvol))
630
631         psidx = tarfile.gen_rescue_index (gen_volume_name,
632                                           mode,
633                                           password=self.PASSWORD)
634
635         # correct for objects spanning volumes: these are treated as separate
636         # in the index!
637         assert len (psidx) - self.VOLUMES + 1 == len (self.hash)
638
639
640 ###############################################################################
641 # rescue
642 ###############################################################################
643
644 class RecoverCorruptPayloadTestBase (RecoverTest):
645     COMPRESSION = None
646     PASSWORD    = None
647     FAILURES    = 0 # tarfile will restore but corrupted, as
648     MISMATCHES  = 1 # revealed by the hash
649
650 class RecoverCorruptPayloadSingleTest (RecoverCorruptPayloadTestBase):
651     VOLUMES     = 1
652
653 class RecoverCorruptPayloadMultiTest (RecoverCorruptPayloadTestBase):
654     VOLUMES     = 3
655
656
657 class RecoverCorruptPayloadGZTestBase (RecoverTest):
658     COMPRESSION = "#gz"
659     PASSWORD    = None
660     FAILURES    = 1
661     MISMATCHES  = 0
662
663 class RecoverCorruptPayloadGZSingleTest (RecoverCorruptPayloadGZTestBase):
664     VOLUMES     = 1
665
666 class RecoverCorruptPayloadGZMultiTest (RecoverCorruptPayloadGZTestBase):
667     VOLUMES     = 3
668
669
670 class RecoverCorruptPayloadGZAESTestBase (RecoverTest):
671     COMPRESSION = "#gz"
672     PASSWORD    = TEST_PASSWORD
673     FAILURES    = 1
674     MISMATCHES  = 0
675
676 class RecoverCorruptPayloadGZAESSingleTest (RecoverCorruptPayloadGZAESTestBase):
677     VOLUMES     = 1
678
679 class RecoverCorruptPayloadGZAESMultiTest (RecoverCorruptPayloadGZAESTestBase):
680     VOLUMES     = 3
681
682
683 class RecoverCorruptHeaderTestBase (RecoverTest):
684     COMPRESSION = None
685     PASSWORD    = None
686     FAILURES    = 1
687     CORRUPT     = corrupt_header
688     MISMATCHES  = 0
689
690 class RecoverCorruptHeaderSingleTest (RecoverCorruptHeaderTestBase):
691     VOLUMES     = 1
692
693 class RecoverCorruptHeaderMultiTest (RecoverCorruptHeaderTestBase):
694     VOLUMES     = 3
695
696
697 class RecoverCorruptHeaderGZTestBase (RecoverTest):
698     COMPRESSION = "#gz"
699     PASSWORD    = None
700     FAILURES    = 1
701     CORRUPT     = corrupt_header
702     MISMATCHES  = 0
703
704 class RecoverCorruptHeaderGZSingleTest (RecoverCorruptHeaderGZTestBase):
705     VOLUMES     = 1
706
707 class RecoverCorruptHeaderGZMultiTest (RecoverCorruptHeaderGZTestBase):
708     VOLUMES     = 3
709
710
711 class RecoverCorruptHeaderGZAESTestBase (RecoverTest):
712     COMPRESSION = "#gz"
713     PASSWORD    = TEST_PASSWORD
714     FAILURES    = 1
715     CORRUPT     = corrupt_header
716     MISMATCHES  = 0
717
718 class RecoverCorruptHeaderGZAESSingleTest (RecoverCorruptHeaderGZAESTestBase):
719     VOLUMES     = 1
720
721 class RecoverCorruptHeaderGZAESMultiTest (RecoverCorruptHeaderGZAESTestBase):
722     VOLUMES     = 3
723
724
725 class RecoverCorruptTruncateTestBase (RecoverTest):
726     COMPRESSION = None
727     PASSWORD    = None
728     FAILURES    = 0
729     CORRUPT     = corrupt_truncate
730     MISMATCHES  = 0
731
732 class RecoverCorruptTruncateTest (RecoverCorruptTruncateTestBase):
733     pass
734
735 class RecoverCorruptTruncateGZTest (RecoverCorruptTruncateTestBase):
736     """Two files that failed missing."""
737     COMPRESSION = "#gz"
738     FAILURES    = 2
739
740 class RecoverCorruptTruncateGZAESTest (RecoverCorruptTruncateTestBase):
741     """Two files that failed missing."""
742     COMPRESSION = "#gz"
743     PASSWORD    = TEST_PASSWORD
744     FAILURES    = 2
745
746
747 class RecoverCorruptEntireHeaderTestBase (RecoverTest):
748     COMPRESSION = None
749     PASSWORD    = None
750     FAILURES    = 1
751     CORRUPT     = corrupt_entire_header
752     MISMATCHES  = 0
753
754 class RecoverCorruptEntireHeaderSingleTest (RecoverCorruptEntireHeaderTestBase):
755     VOLUMES     = 1
756
757 class RecoverCorruptEntireHeaderMultiTest (RecoverCorruptEntireHeaderTestBase):
758     VOLUMES     = 3
759
760
761 class RecoverCorruptEntireHeaderGZTestBase (RecoverTest):
762     COMPRESSION = "#gz"
763     PASSWORD    = None
764     FAILURES    = 1
765     CORRUPT     = corrupt_entire_header
766     MISMATCHES  = 0
767
768 class RecoverCorruptEntireHeaderGZSingleTest (RecoverCorruptEntireHeaderGZTestBase):
769     VOLUMES     = 1
770
771 class RecoverCorruptEntireHeaderGZMultiTest (RecoverCorruptEntireHeaderGZTestBase):
772     VOLUMES     = 3
773
774
775 class RecoverCorruptEntireHeaderGZAESTestBase (RecoverTest):
776     COMPRESSION = "#gz"
777     PASSWORD    = TEST_PASSWORD
778     FAILURES    = 1
779     CORRUPT     = corrupt_entire_header
780     MISMATCHES  = 0
781
782 class RecoverCorruptEntireHeaderGZAESSingleTest (RecoverCorruptEntireHeaderGZAESTestBase):
783     VOLUMES     = 1
784
785 class RecoverCorruptEntireHeaderGZAESMultiTest (RecoverCorruptEntireHeaderGZAESTestBase):
786     VOLUMES     = 3
787
788
789 class RecoverCorruptTrailingDataTestBase (RecoverTest):
790     # plain Tar is indifferent against traling data and the results
791     # are consistent
792     COMPRESSION = None
793     PASSWORD    = None
794     FAILURES    = 0
795     CORRUPT     = corrupt_trailing_data
796     MISMATCHES  = 0
797
798 class RecoverCorruptTrailingDataSingleTest (RecoverCorruptTrailingDataTestBase):
799     VOLUMES     = 1
800
801 class RecoverCorruptTrailingDataMultiTest (RecoverCorruptTrailingDataTestBase):
802     # the last object in first archive has extra bytes somewhere in the
803     # middle because tar itself performs no data checksumming.
804     MISMATCHES  = 1
805     VOLUMES     = 3
806
807
808 class RecoverCorruptTrailingDataGZTestBase (RecoverTest):
809     # reading past the final object will cause decompression failure;
810     # all objects except for the last survive unharmed though
811     COMPRESSION = "#gz"
812     PASSWORD    = None
813     FAILURES    = 1
814     CORRUPT     = corrupt_trailing_data
815     MISMATCHES  = 0
816
817 class RecoverCorruptTrailingDataGZSingleTest (RecoverCorruptTrailingDataGZTestBase):
818     VOLUMES     = 1
819
820 class RecoverCorruptTrailingDataGZMultiTest (RecoverCorruptTrailingDataGZTestBase):
821     VOLUMES     = 3
822     # the last file of the first volume will only contain the data of the
823     # second part which is contained in the second volume. this happens
824     # because the CRC32 is wrong for the first part so it gets discarded, then
825     # the object is recreated from the first header of the second volume,
826     # containing only the remainder of the data.
827     MISMATCHES  = 1
828     MISSING     = 0
829
830
831 class RecoverCorruptTrailingDataGZAESTestBase (RecoverTest):
832     COMPRESSION = "#gz"
833     PASSWORD    = TEST_PASSWORD
834     FAILURES    = 0
835     CORRUPT     = corrupt_trailing_data
836     MISMATCHES  = 0
837
838 class RecoverCorruptTrailingDataGZAESSingleTest (RecoverCorruptTrailingDataGZAESTestBase):
839     VOLUMES     = 1
840
841 class RecoverCorruptTrailingDataGZAESMultiTest (RecoverCorruptTrailingDataGZAESTestBase):
842     VOLUMES     = 3
843
844
845 class RecoverCorruptVolumeBaseTest (RecoverTest):
846     COMPRESSION = None
847     PASSWORD    = None
848     FAILURES    = 8
849     CORRUPT     = corrupt_volume
850     VOLUMES     = 3
851
852 class RecoverCorruptVolumeTest (RecoverCorruptVolumeBaseTest):
853     pass
854
855 class RecoverCorruptVolumeGZTest (RecoverCorruptVolumeBaseTest):
856     COMPRESSION = "#gz"
857
858 class RecoverCorruptVolumeGZAESTest (RecoverCorruptVolumeBaseTest):
859     COMPRESSION = "#gz"
860     PASSWORD    = TEST_PASSWORD
861
862
863 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
864 class RecoverCorruptHoleBaseTest (RecoverTest):
865     """
866     Cut bytes from the middle of a volume.
867
868     Index-based recovery works only up to the hole.
869     """
870     COMPRESSION = None
871     PASSWORD    = None
872     FAILURES    = 3
873     CORRUPT     = corrupt_hole
874     VOLUMES     = 2 # request two vols to swell up the first one
875     MISMATCHES  = 1
876
877 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
878 class RecoverCorruptHoleTest (RecoverCorruptHoleBaseTest):
879     FAILURES    = 2
880
881 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
882 class RecoverCorruptHoleGZTest (RecoverCorruptHoleBaseTest):
883     COMPRESSION = "#gz"
884     MISSING     = 2
885
886 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
887 class RecoverCorruptHoleGZAESTest (RecoverCorruptHoleBaseTest):
888     COMPRESSION = "#gz"
889     PASSWORD    = TEST_PASSWORD
890     MISSING     = 2
891
892 ###############################################################################
893 # rescue
894 ###############################################################################
895
896 class RescueCorruptTruncateTestBase (RescueTest):
897     COMPRESSION = None
898     PASSWORD    = None
899     FAILURES    = 0
900     CORRUPT     = corrupt_truncate
901     MISMATCHES  = 0
902
903 class RescueCorruptTruncateTest (RescueCorruptTruncateTestBase):
904     pass
905
906 class RescueCorruptTruncateGZTest (RescueCorruptTruncateTestBase):
907     """Two files that failed missing."""
908     COMPRESSION = "#gz"
909     MISSING     = 2
910
911 class RescueCorruptTruncateGZAESTest (RescueCorruptTruncateTestBase):
912     """Two files missing but didn’t fail on account of their absence."""
913     COMPRESSION = "#gz"
914     PASSWORD    = TEST_PASSWORD
915     MISSING     = 2
916
917
918 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
919 class RescueCorruptHoleBaseTest (RescueTest):
920     """
921     Cut bytes from the middle of a volume.
922     """
923     COMPRESSION = None
924     PASSWORD    = None
925     FAILURES    = 0
926     CORRUPT     = corrupt_hole
927     VOLUMES     = 2 # request two vols to swell up the first one
928     MISMATCHES  = 2 # intersected by hole
929     MISSING     = 1 # excised by hole
930
931 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
932 class RescueCorruptHoleTest (RescueCorruptHoleBaseTest):
933     pass
934
935 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
936 class RescueCorruptHoleGZTest (RescueCorruptHoleBaseTest):
937     COMPRESSION = "#gz"
938     # the decompressor explodes in our face processing the first dummy, nothing
939     # we can do to recover
940     FAILURES    = 1
941
942 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
943 class RescueCorruptHoleGZAESTest (RescueCorruptHoleBaseTest):
944     COMPRESSION = "#gz"
945     PASSWORD    = TEST_PASSWORD
946     # again, ignoring the crypto errors yields a bad zlib stream causing the
947     # decompressor to abort where the hole begins; the file is extracted up
948     # to this point though
949     FAILURES    = 1
950
951
952 class RescueCorruptHeaderCTSizeGZAESTest (RescueTest):
953     COMPRESSION = "#gz"
954     PASSWORD    = TEST_PASSWORD
955     FAILURES    = 0
956     CORRUPT     = corrupt_ctsize
957     MISMATCHES  = 0
958
959
960 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
961 class RescueCorruptLeadingGarbageTestBase (RescueTest):
962     # plain Tar is indifferent against traling data and the results
963     # are consistent
964     COMPRESSION = None
965     PASSWORD    = None
966     FAILURES    = 0
967     CORRUPT     = corrupt_leading_garbage
968     MISMATCHES  = 0
969
970 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
971 class RescueCorruptLeadingGarbageSingleTest (RescueCorruptLeadingGarbageTestBase):
972     VOLUMES     = 1
973
974 @unittest.skipIf(sys.version_info < (3, 4), "requires recent os library")
975 class RescueCorruptLeadingGarbageMultiTest (RescueCorruptLeadingGarbageTestBase):
976     # the last object in first archive has extra bytes somewhere in the
977     # middle because tar itself performs no data checksumming.
978     MISMATCHES  = 2
979     VOLUMES     = 3
980
981
982 ###############################################################################
983 # index
984 ###############################################################################
985
986 class GenIndexIntactBaseTest (GenIndexTest):
987     """
988     """
989     COMPRESSION = None
990     PASSWORD    = None
991     FAILURES    = 0
992     CORRUPT     = immaculate
993     VOLUMES     = 1
994     MISMATCHES  = 1
995
996 class GenIndexIntactSingleTest (GenIndexIntactBaseTest):
997     pass
998
999 class GenIndexIntactSingleGZTest (GenIndexIntactBaseTest):
1000     COMPRESSION = "#gz"
1001     MISSING     = 2
1002
1003 class GenIndexIntactSingleGZAESTest (GenIndexIntactBaseTest):
1004     COMPRESSION = "#gz"
1005     PASSWORD    = TEST_PASSWORD
1006     MISSING     = 2
1007
1008 class GenIndexIntactMultiTest (GenIndexIntactBaseTest):
1009     VOLUMES     = 3
1010     pass
1011
1012 class GenIndexIntactMultiGZTest (GenIndexIntactBaseTest):
1013     VOLUMES     = 3
1014     COMPRESSION = "#gz"
1015     MISSING     = 2
1016
1017 class GenIndexIntactMultiGZAESTest (GenIndexIntactBaseTest):
1018     VOLUMES     = 3
1019     COMPRESSION = "#gz"
1020     PASSWORD    = TEST_PASSWORD
1021     MISSING     = 2
1022
1023
1024 class GenIndexCorruptTruncateBaseTest (GenIndexTest):
1025     """
1026     Recreate index from file that lacks the latter portion.
1027     """
1028     COMPRESSION = None
1029     PASSWORD    = None
1030     FAILURES    = 0
1031     CORRUPT     = corrupt_truncate
1032     MISSING     = 2
1033
1034 class GenIndexCorruptTruncateTest (GenIndexCorruptTruncateBaseTest):
1035     pass
1036
1037 class GenIndexCorruptTruncateGZTest (GenIndexCorruptTruncateBaseTest):
1038     COMPRESSION = "#gz"
1039
1040 class GenIndexCorruptTruncateGZAESTest (GenIndexCorruptTruncateBaseTest):
1041     COMPRESSION = "#gz"
1042     PASSWORD    = TEST_PASSWORD
1043
1044
1045 class GenIndexCorruptHoleBaseTest (GenIndexTest):
1046     """
1047     Recreate index from file with hole.
1048     """
1049     COMPRESSION = None
1050     PASSWORD    = None
1051     FAILURES    = 0
1052     CORRUPT     = corrupt_hole
1053     VOLUMES     = 1
1054     MISMATCHES  = 1
1055
1056 class GenIndexCorruptHoleTest (GenIndexCorruptHoleBaseTest):
1057     pass
1058
1059 class GenIndexCorruptHoleGZTest (GenIndexCorruptHoleBaseTest):
1060     COMPRESSION = "#gz"
1061     MISSING     = 2
1062
1063 class GenIndexCorruptHoleGZAESTest (GenIndexCorruptHoleBaseTest):
1064     COMPRESSION = "#gz"
1065     PASSWORD    = TEST_PASSWORD
1066     MISSING     = 2
1067
1068
1069 class GenIndexCorruptEntireHeaderBaseTest (GenIndexTest):
1070     """
1071     Recreate index from file with defective headers.
1072     """
1073     COMPRESSION = None
1074     PASSWORD    = None
1075     FAILURES    = 0
1076     CORRUPT     = corrupt_entire_header
1077     VOLUMES     = 1
1078     MISMATCHES  = 1
1079
1080 class GenIndexCorruptEntireHeaderTest (GenIndexCorruptEntireHeaderBaseTest):
1081     pass
1082
1083 class GenIndexCorruptEntireHeaderGZTest (GenIndexCorruptEntireHeaderBaseTest):
1084     COMPRESSION = "#gz"
1085     MISSING     = 2
1086
1087 class GenIndexCorruptEntireHeaderGZAESTest (GenIndexCorruptEntireHeaderBaseTest):
1088     COMPRESSION = "#gz"
1089     PASSWORD    = TEST_PASSWORD
1090     MISSING     = 2
1091