]> arthur.barton.de Git - bup.git/blob - lib/bup/cmd/save.py
Test symlink target changes between stat and readlink
[bup.git] / lib / bup / cmd / save.py
1
2 from __future__ import absolute_import, print_function
3 from binascii import hexlify
4 from errno import EACCES
5 from io import BytesIO
6 import math, os, stat, sys, time
7
8 from bup import compat, hashsplit, git, options, index, client, metadata
9 from bup import hlinkdb
10 from bup.compat import argv_bytes, environ
11 from bup.hashsplit import GIT_MODE_TREE, GIT_MODE_FILE, GIT_MODE_SYMLINK
12 from bup.helpers import (add_error, grafted_path_components, handle_ctrl_c,
13                          hostname, istty2, log, parse_date_or_fatal, parse_num,
14                          path_components, progress, qprogress, resolve_parent,
15                          saved_errors, stripped_path_components,
16                          valid_save_name)
17 from bup.io import byte_stream, path_msg
18 from bup.pwdgrp import userfullname, username
19
20
21 optspec = """
22 bup save [-tc] [-n name] <filenames...>
23 --
24 r,remote=  hostname:/path/to/repo of remote repository
25 t,tree     output a tree id
26 c,commit   output a commit id
27 n,name=    name of backup set to update (if any)
28 d,date=    date for the commit (seconds since the epoch)
29 v,verbose  increase log output (can be used more than once)
30 q,quiet    don't show progress meter
31 smaller=   only back up files smaller than n bytes
32 bwlimit=   maximum bytes/sec to transmit to server
33 f,indexfile=  the name of the index file (normally BUP_DIR/bupindex)
34 strip      strips the path to every filename given
35 strip-path= path-prefix to be stripped when saving
36 graft=     a graft point *old_path*=*new_path* (can be used more than once)
37 #,compress=  set compression level to # (0-9, 9 is highest) [1]
38 """
39
40
41 ### Test hooks
42
43 after_nondir_metadata_stat = None
44
45 def before_saving_regular_file(name):
46     return
47
48
49 def main(argv):
50
51     # Hack around lack of nonlocal vars in python 2
52     _nonlocal = {}
53
54     o = options.Options(optspec)
55     opt, flags, extra = o.parse_bytes(argv[1:])
56
57     if opt.indexfile:
58         opt.indexfile = argv_bytes(opt.indexfile)
59     if opt.name:
60         opt.name = argv_bytes(opt.name)
61     if opt.remote:
62         opt.remote = argv_bytes(opt.remote)
63     if opt.strip_path:
64         opt.strip_path = argv_bytes(opt.strip_path)
65
66     git.check_repo_or_die()
67     if not (opt.tree or opt.commit or opt.name):
68         o.fatal("use one or more of -t, -c, -n")
69     if not extra:
70         o.fatal("no filenames given")
71
72     extra = [argv_bytes(x) for x in extra]
73
74     opt.progress = (istty2 and not opt.quiet)
75     opt.smaller = parse_num(opt.smaller or 0)
76     if opt.bwlimit:
77         client.bwlimit = parse_num(opt.bwlimit)
78
79     if opt.date:
80         date = parse_date_or_fatal(opt.date, o.fatal)
81     else:
82         date = time.time()
83
84     if opt.strip and opt.strip_path:
85         o.fatal("--strip is incompatible with --strip-path")
86
87     graft_points = []
88     if opt.graft:
89         if opt.strip:
90             o.fatal("--strip is incompatible with --graft")
91
92         if opt.strip_path:
93             o.fatal("--strip-path is incompatible with --graft")
94
95         for (option, parameter) in flags:
96             if option == "--graft":
97                 parameter = argv_bytes(parameter)
98                 splitted_parameter = parameter.split(b'=')
99                 if len(splitted_parameter) != 2:
100                     o.fatal("a graft point must be of the form old_path=new_path")
101                 old_path, new_path = splitted_parameter
102                 if not (old_path and new_path):
103                     o.fatal("a graft point cannot be empty")
104                 graft_points.append((resolve_parent(old_path),
105                                      resolve_parent(new_path)))
106
107     is_reverse = environ.get(b'BUP_SERVER_REVERSE')
108     if is_reverse and opt.remote:
109         o.fatal("don't use -r in reverse mode; it's automatic")
110
111     name = opt.name
112     if name and not valid_save_name(name):
113         o.fatal("'%s' is not a valid branch name" % path_msg(name))
114     refname = name and b'refs/heads/%s' % name or None
115     if opt.remote or is_reverse:
116         try:
117             cli = client.Client(opt.remote)
118         except client.ClientError as e:
119             log('error: %s' % e)
120             sys.exit(1)
121         oldref = refname and cli.read_ref(refname) or None
122         w = cli.new_packwriter(compression_level=opt.compress)
123     else:
124         cli = None
125         oldref = refname and git.read_ref(refname) or None
126         w = git.PackWriter(compression_level=opt.compress)
127
128     handle_ctrl_c()
129
130
131     # Metadata is stored in a file named .bupm in each directory.  The
132     # first metadata entry will be the metadata for the current directory.
133     # The remaining entries will be for each of the other directory
134     # elements, in the order they're listed in the index.
135     #
136     # Since the git tree elements are sorted according to
137     # git.shalist_item_sort_key, the metalist items are accumulated as
138     # (sort_key, metadata) tuples, and then sorted when the .bupm file is
139     # created.  The sort_key should have been computed using the element's
140     # mangled name and git mode (after hashsplitting), but the code isn't
141     # actually doing that but rather uses the element's real name and mode.
142     # This makes things a bit more difficult when reading it back, see
143     # vfs.ordered_tree_entries().
144
145     # Maintain a stack of information representing the current location in
146     # the archive being constructed.  The current path is recorded in
147     # parts, which will be something like ['', 'home', 'someuser'], and
148     # the accumulated content and metadata for of the dirs in parts is
149     # stored in parallel stacks in shalists and metalists.
150
151     parts = [] # Current archive position (stack of dir names).
152     shalists = [] # Hashes for each dir in paths.
153     metalists = [] # Metadata for each dir in paths.
154
155
156     def _push(part, metadata):
157         # Enter a new archive directory -- make it the current directory.
158         parts.append(part)
159         shalists.append([])
160         metalists.append([(b'', metadata)]) # This dir's metadata (no name).
161
162
163     def _pop(force_tree, dir_metadata=None):
164         # Leave the current archive directory and add its tree to its parent.
165         assert(len(parts) >= 1)
166         part = parts.pop()
167         shalist = shalists.pop()
168         metalist = metalists.pop()
169         # FIXME: only test if collision is possible (i.e. given --strip, etc.)?
170         if force_tree:
171             tree = force_tree
172         else:
173             names_seen = set()
174             clean_list = []
175             metaidx = 1 # entry at 0 is for the dir
176             for x in shalist:
177                 name = x[1]
178                 if name in names_seen:
179                     parent_path = b'/'.join(parts) + b'/'
180                     add_error('error: ignoring duplicate path %s in %s'
181                               % (path_msg(name), path_msg(parent_path)))
182                     if not stat.S_ISDIR(x[0]):
183                         del metalist[metaidx]
184                 else:
185                     names_seen.add(name)
186                     clean_list.append(x)
187                     if not stat.S_ISDIR(x[0]):
188                         metaidx += 1
189
190             if dir_metadata: # Override the original metadata pushed for this dir.
191                 metalist = [(b'', dir_metadata)] + metalist[1:]
192             sorted_metalist = sorted(metalist, key = lambda x : x[0])
193             metadata = b''.join([m[1].encode() for m in sorted_metalist])
194             metadata_f = BytesIO(metadata)
195             mode, id = hashsplit.split_to_blob_or_tree(w.new_blob, w.new_tree,
196                                                        [metadata_f],
197                                                        keep_boundaries=False)
198             clean_list.append((mode, b'.bupm', id))
199
200             tree = w.new_tree(clean_list)
201         if shalists:
202             shalists[-1].append((GIT_MODE_TREE,
203                                  git.mangle_name(part,
204                                                  GIT_MODE_TREE, GIT_MODE_TREE),
205                                  tree))
206         return tree
207
208
209     _nonlocal['count'] = 0
210     _nonlocal['subcount'] = 0
211     _nonlocal['lastremain'] = None
212
213     def progress_report(n):
214         _nonlocal['subcount'] += n
215         cc = _nonlocal['count'] + _nonlocal['subcount']
216         pct = total and (cc*100.0/total) or 0
217         now = time.time()
218         elapsed = now - tstart
219         kps = elapsed and int(cc/1024./elapsed)
220         kps_frac = 10 ** int(math.log(kps+1, 10) - 1)
221         kps = int(kps/kps_frac)*kps_frac
222         if cc:
223             remain = elapsed*1.0/cc * (total-cc)
224         else:
225             remain = 0.0
226         if (_nonlocal['lastremain'] and (remain > _nonlocal['lastremain'])
227               and ((remain - _nonlocal['lastremain'])/_nonlocal['lastremain'] < 0.05)):
228             remain = _nonlocal['lastremain']
229         else:
230             _nonlocal['lastremain'] = remain
231         hours = int(remain/60/60)
232         mins = int(remain/60 - hours*60)
233         secs = int(remain - hours*60*60 - mins*60)
234         if elapsed < 30:
235             remainstr = ''
236             kpsstr = ''
237         else:
238             kpsstr = '%dk/s' % kps
239             if hours:
240                 remainstr = '%dh%dm' % (hours, mins)
241             elif mins:
242                 remainstr = '%dm%d' % (mins, secs)
243             else:
244                 remainstr = '%ds' % secs
245         qprogress('Saving: %.2f%% (%d/%dk, %d/%d files) %s %s\r'
246                   % (pct, cc/1024, total/1024, fcount, ftotal,
247                      remainstr, kpsstr))
248
249
250     indexfile = opt.indexfile or git.repo(b'bupindex')
251     r = index.Reader(indexfile)
252     try:
253         msr = index.MetaStoreReader(indexfile + b'.meta')
254     except IOError as ex:
255         if ex.errno != EACCES:
256             raise
257         log('error: cannot access %r; have you run bup index?'
258             % path_msg(indexfile))
259         sys.exit(1)
260     hlink_db = hlinkdb.HLinkDB(indexfile + b'.hlink')
261
262     def already_saved(ent):
263         return ent.is_valid() and w.exists(ent.sha) and ent.sha
264
265     def wantrecurse_pre(ent):
266         return not already_saved(ent)
267
268     def wantrecurse_during(ent):
269         return not already_saved(ent) or ent.sha_missing()
270
271     def find_hardlink_target(hlink_db, ent):
272         if hlink_db and not stat.S_ISDIR(ent.mode) and ent.nlink > 1:
273             link_paths = hlink_db.node_paths(ent.dev, ent.ino)
274             if link_paths:
275                 return link_paths[0]
276
277     total = ftotal = 0
278     if opt.progress:
279         for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_pre):
280             if not (ftotal % 10024):
281                 qprogress('Reading index: %d\r' % ftotal)
282             exists = ent.exists()
283             hashvalid = already_saved(ent)
284             ent.set_sha_missing(not hashvalid)
285             if not opt.smaller or ent.size < opt.smaller:
286                 if exists and not hashvalid:
287                     total += ent.size
288             ftotal += 1
289         progress('Reading index: %d, done.\n' % ftotal)
290         hashsplit.progress_callback = progress_report
291
292     # Root collisions occur when strip or graft options map more than one
293     # path to the same directory (paths which originally had separate
294     # parents).  When that situation is detected, use empty metadata for
295     # the parent.  Otherwise, use the metadata for the common parent.
296     # Collision example: "bup save ... --strip /foo /foo/bar /bar".
297
298     # FIXME: Add collision tests, or handle collisions some other way.
299
300     # FIXME: Detect/handle strip/graft name collisions (other than root),
301     # i.e. if '/foo/bar' and '/bar' both map to '/'.
302
303     first_root = None
304     root_collision = None
305     tstart = time.time()
306     fcount = 0
307     lastskip_name = None
308     lastdir = b''
309     for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_during):
310         (dir, file) = os.path.split(ent.name)
311         exists = (ent.flags & index.IX_EXISTS)
312         hashvalid = already_saved(ent)
313         wasmissing = ent.sha_missing()
314         oldsize = ent.size
315         if opt.verbose:
316             if not exists:
317                 status = 'D'
318             elif not hashvalid:
319                 if ent.sha == index.EMPTY_SHA:
320                     status = 'A'
321                 else:
322                     status = 'M'
323             else:
324                 status = ' '
325             if opt.verbose >= 2:
326                 log('%s %-70s\n' % (status, path_msg(ent.name)))
327             elif not stat.S_ISDIR(ent.mode) and lastdir != dir:
328                 if not lastdir.startswith(dir):
329                     log('%s %-70s\n' % (status, path_msg(os.path.join(dir, b''))))
330                 lastdir = dir
331
332         if opt.progress:
333             progress_report(0)
334         fcount += 1
335
336         if not exists:
337             continue
338         if opt.smaller and ent.size >= opt.smaller:
339             if exists and not hashvalid:
340                 if opt.verbose:
341                     log('skipping large file "%s"\n' % path_msg(ent.name))
342                 lastskip_name = ent.name
343             continue
344
345         assert(dir.startswith(b'/'))
346         if opt.strip:
347             dirp = stripped_path_components(dir, extra)
348         elif opt.strip_path:
349             dirp = stripped_path_components(dir, [opt.strip_path])
350         elif graft_points:
351             dirp = grafted_path_components(graft_points, dir)
352         else:
353             dirp = path_components(dir)
354
355         # At this point, dirp contains a representation of the archive
356         # path that looks like [(archive_dir_name, real_fs_path), ...].
357         # So given "bup save ... --strip /foo/bar /foo/bar/baz", dirp
358         # might look like this at some point:
359         #   [('', '/foo/bar'), ('baz', '/foo/bar/baz'), ...].
360
361         # This dual representation supports stripping/grafting, where the
362         # archive path may not have a direct correspondence with the
363         # filesystem.  The root directory is represented by an initial
364         # component named '', and any component that doesn't have a
365         # corresponding filesystem directory (due to grafting, for
366         # example) will have a real_fs_path of None, i.e. [('', None),
367         # ...].
368
369         if first_root == None:
370             first_root = dirp[0]
371         elif first_root != dirp[0]:
372             root_collision = True
373
374         # If switching to a new sub-tree, finish the current sub-tree.
375         while parts > [x[0] for x in dirp]:
376             _pop(force_tree = None)
377
378         # If switching to a new sub-tree, start a new sub-tree.
379         for path_component in dirp[len(parts):]:
380             dir_name, fs_path = path_component
381             # Not indexed, so just grab the FS metadata or use empty metadata.
382             try:
383                 meta = metadata.from_path(fs_path, normalized=True) \
384                     if fs_path else metadata.Metadata()
385             except (OSError, IOError) as e:
386                 add_error(e)
387                 lastskip_name = dir_name
388                 meta = metadata.Metadata()
389             _push(dir_name, meta)
390
391         if not file:
392             if len(parts) == 1:
393                 continue # We're at the top level -- keep the current root dir
394             # Since there's no filename, this is a subdir -- finish it.
395             oldtree = already_saved(ent) # may be None
396             newtree = _pop(force_tree = oldtree)
397             if not oldtree:
398                 if lastskip_name and lastskip_name.startswith(ent.name):
399                     ent.invalidate()
400                 else:
401                     ent.validate(GIT_MODE_TREE, newtree)
402                 ent.repack()
403             if exists and wasmissing:
404                 _nonlocal['count'] += oldsize
405             continue
406
407         # it's not a directory
408         if hashvalid:
409             id = ent.sha
410             git_name = git.mangle_name(file, ent.mode, ent.gitmode)
411             git_info = (ent.gitmode, git_name, id)
412             shalists[-1].append(git_info)
413             sort_key = git.shalist_item_sort_key((ent.mode, file, id))
414             meta = msr.metadata_at(ent.meta_ofs)
415             meta.hardlink_target = find_hardlink_target(hlink_db, ent)
416             # Restore the times that were cleared to 0 in the metastore.
417             (meta.atime, meta.mtime, meta.ctime) = (ent.atime, ent.mtime, ent.ctime)
418             metalists[-1].append((sort_key, meta))
419         else:
420             id = None
421             hlink = find_hardlink_target(hlink_db, ent)
422             try:
423                 meta = metadata.from_path(ent.name, hardlink_target=hlink,
424                                           normalized=True,
425                                           after_stat=after_nondir_metadata_stat)
426             except (OSError, IOError) as e:
427                 add_error(e)
428                 lastskip_name = ent.name
429                 continue
430             if stat.S_IFMT(ent.mode) != stat.S_IFMT(meta.mode):
431                 # The mode changed since we indexed the file, this is bad.
432                 # This can cause two issues:
433                 # 1) We e.g. think the file is a regular file, but now it's
434                 #    something else (a device, socket, FIFO or symlink, etc.)
435                 #    and _read_ from it when we shouldn't.
436                 # 2) We then record it as valid, but don't update the index
437                 #    metadata, and on a subsequent save it has 'hashvalid'
438                 #    but is recorded as the file type from the index, when
439                 #    the content is something else ...
440                 # Avoid all of these consistency issues by just skipping such
441                 # things - it really ought to not happen anyway.
442                 add_error("%s: mode changed since indexing, skipping." % path_msg(ent.name))
443                 lastskip_name = ent.name
444                 continue
445             if stat.S_ISREG(ent.mode):
446                 try:
447                     # If the file changes while we're reading it, then our reading
448                     # may stop at some point, but the stat() above may have gotten
449                     # a different size already. Recalculate the meta size so that
450                     # the repository records the accurate size in the metadata, even
451                     # if the other stat() data might be slightly older than the file
452                     # content (which we can't fix, this is inherently racy, but we
453                     # can prevent the size mismatch.)
454                     meta.size = 0
455                     def new_blob(data):
456                         meta.size += len(data)
457                         return w.new_blob(data)
458                     before_saving_regular_file(ent.name)
459                     with hashsplit.open_noatime(ent.name) as f:
460                         (mode, id) = hashsplit.split_to_blob_or_tree(
461                                                 new_blob, w.new_tree, [f],
462                                                 keep_boundaries=False)
463                 except (IOError, OSError) as e:
464                     add_error('%s: %s' % (ent.name, e))
465                     lastskip_name = ent.name
466             elif stat.S_ISDIR(ent.mode):
467                 assert(0)  # handled above
468             elif stat.S_ISLNK(ent.mode):
469                 mode, id = (GIT_MODE_SYMLINK, w.new_blob(meta.symlink_target))
470             else:
471                 # Everything else should be fully described by its
472                 # metadata, so just record an empty blob, so the paths
473                 # in the tree and .bupm will match up.
474                 (mode, id) = (GIT_MODE_FILE, w.new_blob(b''))
475
476             if id:
477                 ent.validate(mode, id)
478                 ent.repack()
479                 git_name = git.mangle_name(file, ent.mode, ent.gitmode)
480                 git_info = (mode, git_name, id)
481                 shalists[-1].append(git_info)
482                 sort_key = git.shalist_item_sort_key((ent.mode, file, id))
483                 metalists[-1].append((sort_key, meta))
484
485         if exists and wasmissing:
486             _nonlocal['count'] += oldsize
487             _nonlocal['subcount'] = 0
488
489
490     if opt.progress:
491         pct = total and _nonlocal['count']*100.0/total or 100
492         progress('Saving: %.2f%% (%d/%dk, %d/%d files), done.    \n'
493                  % (pct, _nonlocal['count']/1024, total/1024, fcount, ftotal))
494
495     while len(parts) > 1: # _pop() all the parts above the root
496         _pop(force_tree = None)
497     assert(len(shalists) == 1)
498     assert(len(metalists) == 1)
499
500     # Finish the root directory.
501     tree = _pop(force_tree = None,
502                 # When there's a collision, use empty metadata for the root.
503                 dir_metadata = metadata.Metadata() if root_collision else None)
504
505     sys.stdout.flush()
506     out = byte_stream(sys.stdout)
507
508     if opt.tree:
509         out.write(hexlify(tree))
510         out.write(b'\n')
511     if opt.commit or name:
512         if compat.py_maj > 2:
513             # Strip b prefix from python 3 bytes reprs to preserve previous format
514              msgcmd = b'[%s]' % b', '.join([repr(argv_bytes(x))[1:].encode('ascii')
515                                            for x in argv])
516         else:
517             msgcmd = repr(argv)
518         msg = b'bup save\n\nGenerated by command:\n%s\n' % msgcmd
519         userline = (b'%s <%s@%s>' % (userfullname(), username(), hostname()))
520         commit = w.new_commit(tree, oldref, userline, date, None,
521                               userline, date, None, msg)
522         if opt.commit:
523             out.write(hexlify(commit))
524             out.write(b'\n')
525
526     msr.close()
527     w.close()  # must close before we can update the ref
528
529     if opt.name:
530         if cli:
531             cli.update_ref(refname, commit, oldref)
532         else:
533             git.update_ref(refname, commit, oldref)
534
535     if cli:
536         cli.close()
537
538     if saved_errors:
539         log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
540         sys.exit(1)