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