Merge lp:~mterry/duplicity/static-corruption into lp:duplicity/0.6

Proposed by Michael Terry
Status: Merged
Merged at revision: 903
Proposed branch: lp:~mterry/duplicity/static-corruption
Merge into: lp:duplicity/0.6
Diff against target: 498 lines (+188/-47)
6 files modified
bin/duplicity (+13/-5)
duplicity/diffdir.py (+21/-19)
duplicity/dup_temp.py (+4/-2)
duplicity/gpg.py (+4/-6)
testing/tests/gpgtest.py (+10/-5)
testing/tests/restarttest.py (+136/-10)
To merge this branch: bzr merge lp:~mterry/duplicity/static-corruption
Reviewer Review Type Date Requested Status
duplicity-team Pending
Review via email: mp+142044@code.launchpad.net

Description of the change

This branch fixes three possible ways a backup could get data-corrupted. Inspired by bug 1091269.

A) If resuming after a volume that ended in a one-block file, we would skip the first block of the next file.
B) If resuming after a volume that ended in a multi-block file, we would skip the first block of the next file.
C) If resuming after a volume that spanned a multi-block file, we would skip some data inside the file.

A and B are because when finding the right place in the source files to restart the backup, the iteration loop didn't handle None block numbers very well (which are used to indicate the end of a file).

C is what bug 1091269 talks about. This was because data block sizes would get smaller as the difftar file got closer and closer to the volsize. Standard block sizes were 64 * 1024. But say we were close to the end of the difftar... When resuming, duplicity doesn't know the custom block sizes used by the previous run, so it uses standard block sizes. And it doesn't always match up, as you can imagine. So we would leave chunks of data out of the backed up file.

Tests added for these cases.

This branch is called 'static-corruption' because all these issues occur even when the source data doesn't change. I still think there are some corruption issues when a file changes in between duplicity runs. I haven't started looking into that yet, but that's next on my list.

To post a comment you must log in.
Revision history for this message
Michael Terry (mterry) wrote :

I forgot to mention, C only happened without encryption (because the gpg writer function already happened to force a constant data block size). A and B happened with or without encryption.

909. By Michael Terry

whoops, fix a test I broke with last commit because I didn't add get_read_size to the mock writer

910. By Michael Terry

add test to confirm expected behavior if new files appear before we restart a backup

Revision history for this message
Michael Terry (mterry) wrote :

Eh, I mentioned looking into fixing issues around when a file changes in between duplicity runs. I don't think there's corruption, just the expected "first half is different than second half" issue. Not sure what we want to do about that if anything.

This branch is ready for review.

911. By Michael Terry

use a copy of file1 rather than writing out a new small file so that it is clearer the first block is skipped rather than the whole file

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/duplicity'
2--- bin/duplicity 2012-11-21 01:27:35 +0000
3+++ bin/duplicity 2013-01-07 16:15:25 +0000
4@@ -223,7 +223,13 @@
5 # Just spin our wheels
6 while tarblock_iter.next():
7 if (tarblock_iter.previous_index == last_index):
8- if (tarblock_iter.previous_block > last_block):
9+ # If both the previous index and this index are done, exit now
10+ # before we hit the next index, to prevent skipping its first
11+ # block.
12+ if not last_block and not tarblock_iter.previous_block:
13+ break
14+ # Only check block number if last_block is also a number
15+ if last_block and tarblock_iter.previous_block > last_block:
16 break
17 if tarblock_iter.previous_index > last_index:
18 log.Warn(_("File %s complete in backup set.\n"
19@@ -937,11 +943,10 @@
20 """
21 Copy data from src_iter to file at fn
22 """
23- block_size = 128 * 1024
24 file = open(filename, "wb")
25 while True:
26 try:
27- data = src_iter.next(block_size).data
28+ data = src_iter.next().data
29 except StopIteration:
30 break
31 file.write(data)
32@@ -989,9 +994,9 @@
33 def __init__(self, fileobj):
34 self.fileobj = fileobj
35
36- def next(self, size):
37+ def next(self):
38 try:
39- res = Block(self.fileobj.read(size))
40+ res = Block(self.fileobj.read(self.get_read_size()))
41 except Exception:
42 if hasattr(self.fileobj, 'name'):
43 name = self.fileobj.name
44@@ -1005,6 +1010,9 @@
45 raise StopIteration
46 return res
47
48+ def get_read_size(self):
49+ return 128 * 1024
50+
51 def get_footer(self):
52 return ""
53
54
55=== modified file 'duplicity/diffdir.py'
56--- duplicity/diffdir.py 2012-09-28 15:48:21 +0000
57+++ duplicity/diffdir.py 2013-01-07 16:15:25 +0000
58@@ -481,14 +481,14 @@
59 filler_data = ""
60 return TarBlock(index, "%s%s%s" % (headers, file_data, filler_data))
61
62- def process(self, val, size):
63+ def process(self, val):
64 """
65 Turn next value of input_iter into a TarBlock
66 """
67 assert not self.process_waiting
68 XXX # Override in subclass @UndefinedVariable
69
70- def process_continued(self, size):
71+ def process_continued(self):
72 """
73 Get more tarblocks
74
75@@ -498,15 +498,15 @@
76 assert self.process_waiting
77 XXX # Override in subclass @UndefinedVariable
78
79- def next(self, size = 1024 * 1024):
80+ def next(self):
81 """
82- Return next block, no bigger than size, and update offset
83+ Return next block and update offset
84 """
85 if self.process_waiting:
86- result = self.process_continued(size)
87+ result = self.process_continued()
88 else:
89 # Below a StopIteration exception will just be passed upwards
90- result = self.process(self.input_iter.next(), size)
91+ result = self.process(self.input_iter.next())
92 block_number = self.process_next_vol_number
93 self.offset += len(result.data)
94 self.previous_index = result.index
95@@ -517,6 +517,13 @@
96 self.remember_next = False
97 return result
98
99+ def get_read_size(self):
100+ # read size must always be the same, because if we are restarting a
101+ # backup volume where the previous volume ended in a data block, we
102+ # have to be able to assume it's length in order to continue reading
103+ # the file from the right place.
104+ return 64 * 1024
105+
106 def get_previous_index(self):
107 """
108 Return index of last tarblock, or None if no previous index
109@@ -553,7 +560,7 @@
110 """
111 TarBlockIter that does no file reading
112 """
113- def process(self, delta_ropath, size):
114+ def process(self, delta_ropath):
115 """
116 Get a fake tarblock from delta_ropath
117 """
118@@ -577,13 +584,9 @@
119 """
120 TarBlockIter that yields blocks of a signature tar from path_iter
121 """
122- def process(self, path, size):
123+ def process(self, path):
124 """
125 Return associated signature TarBlock from path
126-
127- Here size is just ignored --- let's hope a signature isn't too
128- big. Also signatures are stored in multiple volumes so it
129- doesn't matter.
130 """
131 ti = path.get_tarinfo()
132 if path.isreg():
133@@ -606,7 +609,7 @@
134 delta_path_iter, so the delta information has already been
135 calculated.
136 """
137- def process(self, delta_ropath, size):
138+ def process(self, delta_ropath):
139 """
140 Get a tarblock from delta_ropath
141 """
142@@ -631,8 +634,7 @@
143
144 # Now handle single volume block case
145 fp = delta_ropath.open("rb")
146- # Below the 512 is the usual length of a tar header
147- data, last_block = self.get_data_block(fp, size - 512)
148+ data, last_block = self.get_data_block(fp)
149 if stats:
150 stats.RawDeltaSize += len(data)
151 if last_block:
152@@ -654,11 +656,11 @@
153 self.process_next_vol_number = 2
154 return self.tarinfo2tarblock(index, ti, data)
155
156- def get_data_block(self, fp, max_size):
157+ def get_data_block(self, fp):
158 """
159 Return pair (next data block, boolean last data block)
160 """
161- read_size = min(64*1024, max(max_size, 512))
162+ read_size = self.get_read_size()
163 buf = fp.read(read_size)
164 if len(buf) < read_size:
165 if fp.close():
166@@ -667,7 +669,7 @@
167 else:
168 return (buf, False)
169
170- def process_continued(self, size):
171+ def process_continued(self):
172 """
173 Return next volume in multivol diff or snapshot
174 """
175@@ -675,7 +677,7 @@
176 ropath = self.process_ropath
177 ti, index = ropath.get_tarinfo(), ropath.index
178 ti.name = "%s/%d" % (self.process_prefix, self.process_next_vol_number)
179- data, last_block = self.get_data_block(self.process_fp, size - 512)
180+ data, last_block = self.get_data_block(self.process_fp)
181 if stats:
182 stats.RawDeltaSize += len(data)
183 if last_block:
184
185=== modified file 'duplicity/dup_temp.py'
186--- duplicity/dup_temp.py 2012-09-28 15:48:21 +0000
187+++ duplicity/dup_temp.py 2013-01-07 16:15:25 +0000
188@@ -256,9 +256,9 @@
189 def __init__(self, src):
190 self.src = src
191 self.fp = src.open("rb")
192- def next(self, size):
193+ def next(self):
194 try:
195- res = Block(self.fp.read(size))
196+ res = Block(self.fp.read(self.get_read_size()))
197 except Exception:
198 log.FatalError(_("Failed to read %s: %s") %
199 (self.src.name, sys.exc_info()),
200@@ -267,5 +267,7 @@
201 self.fp.close()
202 raise StopIteration
203 return res
204+ def get_read_size(self):
205+ return 128 * 1024
206 def get_footer(self):
207 return ""
208
209=== modified file 'duplicity/gpg.py'
210--- duplicity/gpg.py 2013-01-02 14:02:35 +0000
211+++ duplicity/gpg.py 2013-01-07 16:15:25 +0000
212@@ -319,17 +319,16 @@
213 def get_current_size():
214 return os.stat(filename).st_size
215
216- block_size = 128 * 1024 # don't bother requesting blocks smaller, but also don't ask for bigger
217 target_size = size - 50 * 1024 # fudge factor, compensate for gpg buffering
218 data_size = target_size - max_footer_size
219 file = GPGFile(True, path.Path(filename), profile)
220 at_end_of_blockiter = 0
221 while True:
222 bytes_to_go = data_size - get_current_size()
223- if bytes_to_go < block_size:
224+ if bytes_to_go < block_iter.get_read_size():
225 break
226 try:
227- data = block_iter.next(min(block_size, bytes_to_go)).data
228+ data = block_iter.next().data
229 except StopIteration:
230 at_end_of_blockiter = 1
231 break
232@@ -373,16 +372,15 @@
233 def close(self):
234 return self.fileobj.close()
235
236- block_size = 64 * 1024
237 file_counted = FileCounted(open(filename, "wb"))
238 gzip_file = gzip.GzipFile(None, "wb", 6, file_counted)
239 at_end_of_blockiter = 0
240 while True:
241 bytes_to_go = size - file_counted.byte_count
242- if bytes_to_go < block_size:
243+ if bytes_to_go < block_iter.get_read_size():
244 break
245 try:
246- new_block = block_iter.next(min(block_size, bytes_to_go))
247+ new_block = block_iter.next()
248 except StopIteration:
249 at_end_of_blockiter = 1
250 break
251
252=== modified file 'testing/tests/gpgtest.py'
253--- testing/tests/gpgtest.py 2012-11-24 19:45:09 +0000
254+++ testing/tests/gpgtest.py 2013-01-07 16:15:25 +0000
255@@ -165,7 +165,7 @@
256 #print os.stat("testfiles/output/gzwrite.gz").st_size-size
257 assert size - 64 * 1024 <= os.stat("testfiles/output/gzwrite.gz").st_size <= size + 64 * 1024
258 gwfh.set_at_end()
259- gpg.GzipWriteFile(gwfh, "testfiles/output/gzwrite.gpg", size = size)
260+ gpg.GzipWriteFile(gwfh, "testfiles/output/gzwrite.gz", size = size)
261 #print os.stat("testfiles/output/gzwrite.gz").st_size
262
263
264@@ -188,13 +188,18 @@
265 s2 = size - s1
266 return "a"*s1 + self.from_random_fp.read(s2)
267
268- def next(self, size):
269+ def next(self):
270 if self.at_end: raise StopIteration
271- if random.randrange(2): real_size = size
272- else: real_size = random.randrange(0, size)
273- block_data = self.get_buffer(real_size)
274+ block_data = self.get_buffer(self.get_read_size())
275 return GPGWriteHelper2(block_data)
276
277+ def get_read_size(self):
278+ size = 64 * 1024
279+ if random.randrange(2):
280+ return size
281+ else:
282+ return random.randrange(0, size)
283+
284 def get_footer(self):
285 return "e" * random.randrange(0, 15000)
286
287
288=== modified file 'testing/tests/restarttest.py'
289--- testing/tests/restarttest.py 2012-11-24 19:45:09 +0000
290+++ testing/tests/restarttest.py 2013-01-07 16:15:25 +0000
291@@ -36,7 +36,6 @@
292 other_args = ["-v0", "--no-print-statistics"]
293 #other_args = ["--short-filenames"]
294 #other_args = ["--ssh-command 'ssh -v'", "--scp-command 'scp -C'"]
295-#other_args = ['--no-encryption']
296
297 # If this is set to true, after each backup, verify contents
298 verify = 1
299@@ -52,8 +51,9 @@
300 Test checkpoint/restart using duplicity binary
301 """
302 def setUp(self):
303+ self.class_args = []
304 assert not os.system("tar xzf testfiles.tar.gz > /dev/null 2>&1")
305- assert not os.system("rm -rf testfiles/output "
306+ assert not os.system("rm -rf testfiles/output testfiles/largefiles "
307 "testfiles/restore_out testfiles/cache")
308 assert not os.system("mkdir testfiles/output testfiles/cache")
309 backend = duplicity.backend.get_backend(backend_url)
310@@ -80,6 +80,7 @@
311 cmd_list.append("--current-time %s" % (current_time,))
312 if other_args:
313 cmd_list.extend(other_args)
314+ cmd_list.extend(self.class_args)
315 cmd_list.extend(arglist)
316 cmd_list.extend(["<", "/dev/null"])
317 cmdline = " ".join(cmd_list)
318@@ -144,11 +145,14 @@
319 self.verify(dirname,
320 time = current_time, options = restore_options)
321
322- def make_largefiles(self):
323- # create 3 2M files
324+ def make_largefiles(self, count=3, size=2):
325+ """
326+ Makes a number of large files in testfiles/largefiles that each are
327+ the specified number of megabytes.
328+ """
329 assert not os.system("mkdir testfiles/largefiles")
330- for n in (1,2,3):
331- assert not os.system("dd if=/dev/urandom of=testfiles/largefiles/file%d bs=1024 count=2048 > /dev/null 2>&1" % n)
332+ for n in range(count):
333+ assert not os.system("dd if=/dev/urandom of=testfiles/largefiles/file%d bs=1024 count=%d > /dev/null 2>&1" % (n + 1, size * 1024))
334
335 def check_same(self, filename1, filename2):
336 """
337@@ -327,6 +331,128 @@
338 self.backup("inc", "testfiles/largefiles")
339 self.verify("testfiles/largefiles")
340
341+ def make_fake_second_volume(self, name):
342+ """
343+ Takes a successful backup and pretend that we interrupted a backup
344+ after two-volumes. (This is because we want to be able to model
345+ restarting the second volume and duplicity deletes the last volume
346+ found because it may have not finished uploading.)
347+ """
348+ # First, confirm that we have signs of a successful backup
349+ self.assertEqual(len(glob.glob("testfiles/output/*.manifest*")), 1)
350+ self.assertEqual(len(glob.glob("testfiles/output/*.sigtar*")), 1)
351+ self.assertEqual(len(glob.glob("testfiles/cache/%s/*" % name)), 2)
352+ self.assertEqual(len(glob.glob(
353+ "testfiles/cache/%s/*.manifest*" % name)), 1)
354+ self.assertEqual(len(glob.glob(
355+ "testfiles/cache/%s/*.sigtar*" % name)), 1)
356+ # Alright, everything is in order; fake a second interrupted volume
357+ assert not os.system("rm testfiles/output/*.manifest*")
358+ assert not os.system("rm testfiles/output/*.sigtar*")
359+ assert not os.system("rm -f testfiles/output/*.vol[23456789].*")
360+ assert not os.system("rm -f testfiles/output/*.vol1[^.]+.*")
361+ self.assertEqual(len(glob.glob("testfiles/output/*.difftar*")), 1)
362+ assert not os.system("rm testfiles/cache/%s/*.sigtar*" % name)
363+ assert not os.system("cp testfiles/output/*.difftar* "
364+ "`ls testfiles/output/*.difftar* | "
365+ " sed 's|vol1|vol2|'`")
366+ assert not os.system("head -n6 testfiles/cache/%s/*.manifest > "
367+ "testfiles/cache/%s/"
368+ "`basename testfiles/cache/%s/*.manifest`"
369+ ".part" % (name, name, name))
370+ assert not os.system("rm testfiles/cache/%s/*.manifest" % name)
371+ assert not os.system("""echo 'Volume 2:
372+ StartingPath foo
373+ EndingPath bar
374+ Hash SHA1 sha1' >> testfiles/cache/%s/*.manifest.part""" % name)
375+
376+ def test_split_after_small(self):
377+ """
378+ If we restart right after a volume that ended with a small
379+ (one-block) file, make sure we restart in the right place.
380+ """
381+ source = 'testfiles/largefiles'
382+ assert not os.system("mkdir -p %s" % source)
383+ assert not os.system("echo hello > %s/file1" % source)
384+ self.backup("full", source, options=["--name=backup1"])
385+ # Fake an interruption
386+ self.make_fake_second_volume("backup1")
387+ # Add new file
388+ assert not os.system("cp %s/file1 %s/newfile" % (source, source))
389+ # 'restart' the backup
390+ self.backup("full", source, options=["--name=backup1"])
391+ # Confirm we actually resumed the previous backup
392+ self.assertEqual(len(os.listdir("testfiles/output")), 4)
393+ # Now make sure everything is byte-for-byte the same once restored
394+ self.restore()
395+ assert not os.system("diff -r %s testfiles/restore_out" % source)
396+
397+ def test_split_after_large(self):
398+ """
399+ If we restart right after a volume that ended with a large
400+ (multi-block) file, make sure we restart in the right place.
401+ """
402+ source = 'testfiles/largefiles'
403+ self.make_largefiles(count=1, size=1)
404+ self.backup("full", source, options=["--name=backup1"])
405+ # Fake an interruption
406+ self.make_fake_second_volume("backup1")
407+ # Add new file
408+ assert not os.system("cp %s/file1 %s/newfile" % (source, source))
409+ # 'restart' the backup
410+ self.backup("full", source, options=["--name=backup1"])
411+ # Confirm we actually resumed the previous backup
412+ self.assertEqual(len(os.listdir("testfiles/output")), 4)
413+ # Now make sure everything is byte-for-byte the same once restored
414+ self.restore()
415+ assert not os.system("diff -r %s testfiles/restore_out" % source)
416+
417+ def test_split_inside_large(self):
418+ """
419+ If we restart right after a volume that ended inside of a large
420+ (multi-block) file, make sure we restart in the right place.
421+ """
422+ source = 'testfiles/largefiles'
423+ self.make_largefiles(count=1, size=3)
424+ self.backup("full", source, options=["--vols 1", "--name=backup1"])
425+ # Fake an interruption
426+ self.make_fake_second_volume("backup1")
427+ # 'restart' the backup
428+ self.backup("full", source, options=["--vols 1", "--name=backup1"])
429+ # Now make sure everything is byte-for-byte the same once restored
430+ self.restore()
431+ assert not os.system("diff -r %s testfiles/restore_out" % source)
432+
433+ def test_new_file(self):
434+ """
435+ If we restart right after a volume, but there are new files that would
436+ have been backed up earlier in the volume, make sure we don't wig out.
437+ (Expected result is to ignore new, ealier files, but pick up later
438+ ones.)
439+ """
440+ source = 'testfiles/largefiles'
441+ self.make_largefiles(count=1, size=1)
442+ self.backup("full", source, options=["--name=backup1"])
443+ # Fake an interruption
444+ self.make_fake_second_volume("backup1")
445+ # Add new files, earlier and later in filename sort order
446+ assert not os.system("echo hello > %s/a" % source)
447+ assert not os.system("echo hello > %s/z" % source)
448+ # 'restart' the backup
449+ self.backup("full", source, options=["--name=backup1"])
450+ # Now make sure everything is the same once restored, except 'a'
451+ self.restore()
452+ assert not os.system("test ! -e testfiles/restore_out/a")
453+ assert not os.system("diff %s/file1 testfiles/restore_out/file1" % source)
454+ assert not os.system("diff %s/z testfiles/restore_out/z" % source)
455+
456+
457+# Note that this class duplicates all the tests in RestartTest
458+class RestartTestWithoutEncryption(RestartTest):
459+ def setUp(self):
460+ RestartTest.setUp(self)
461+ self.class_args.extend(["--no-encryption"])
462+
463 def test_no_write_double_snapshot(self):
464 """
465 Test that restarting a full backup does not write duplicate entries
466@@ -337,12 +463,12 @@
467 self.make_largefiles()
468 # Start backup
469 try:
470- self.backup("full", "testfiles/largefiles", options = ["--fail 2", "--vols 1", "--no-encryption"])
471+ self.backup("full", "testfiles/largefiles", options = ["--fail 2", "--vols 1"])
472 self.fail()
473 except CmdError, e:
474 self.assertEqual(30, e.exit_status)
475 # Finish it
476- self.backup("full", "testfiles/largefiles", options = ["--no-encryption"])
477+ self.backup("full", "testfiles/largefiles")
478 # Now check sigtar
479 sigtars = glob.glob("testfiles/output/duplicity-full*.sigtar.gz")
480 self.assertEqual(1, len(sigtars))
481@@ -359,7 +485,7 @@
482 https://launchpad.net/bugs/929067
483 """
484 # Intial normal backup
485- self.backup("full", "testfiles/blocktartest", options = ["--no-encryption"])
486+ self.backup("full", "testfiles/blocktartest")
487 # Create an exact clone of the snapshot folder in the sigtar already.
488 # Permissions and mtime must match.
489 os.mkdir("testfiles/snapshot", 0755)
490@@ -379,7 +505,7 @@
491 self.assertEqual(0, os.system("rm -r testfiles/cache"))
492 # Try a follow on incremental (which in buggy versions, would create
493 # a deleted entry for the base dir)
494- self.backup("inc", "testfiles/blocktartest", options = ["--no-encryption"])
495+ self.backup("inc", "testfiles/blocktartest")
496 self.assertEqual(1, len(glob.glob("testfiles/output/duplicity-new*.sigtar.gz")))
497 # Confirm we can restore it (which in buggy versions, would fail)
498 self.restore()

Subscribers

People subscribed via source and target branches

to all changes: