]> arthur.barton.de Git - bup.git/blob - cmd/restore-cmd.py
Use absolute_import from the __future__ everywhere
[bup.git] / cmd / restore-cmd.py
1 #!/bin/sh
2 """": # -*-python-*-
3 bup_python="$(dirname "$0")/bup-python" || exit $?
4 exec "$bup_python" "$0" ${1+"$@"}
5 """
6 # end of bup preamble
7
8 from __future__ import absolute_import, print_function
9 from stat import S_ISDIR
10 import copy, errno, os, sys, stat, re
11
12 from bup import options, git, metadata, vfs
13 from bup._helpers import write_sparsely
14 from bup.compat import wrap_main
15 from bup.helpers import (add_error, chunkyreader, die_if_errors, handle_ctrl_c,
16                          log, mkdirp, parse_rx_excludes, progress, qprogress,
17                          saved_errors, should_rx_exclude_path, unlink)
18 from bup.repo import LocalRepo, RemoteRepo
19
20
21 optspec = """
22 bup restore [-r host:path] [-C outdir] </branch/revision/path/to/dir ...>
23 --
24 r,remote=   remote repository path
25 C,outdir=   change to given outdir before extracting files
26 numeric-ids restore numeric IDs (user, group, etc.) rather than names
27 exclude-rx= skip paths matching the unanchored regex (may be repeated)
28 exclude-rx-from= skip --exclude-rx patterns in file (may be repeated)
29 sparse      create sparse files
30 v,verbose   increase log output (can be used more than once)
31 map-user=   given OLD=NEW, restore OLD user as NEW user
32 map-group=  given OLD=NEW, restore OLD group as NEW group
33 map-uid=    given OLD=NEW, restore OLD uid as NEW uid
34 map-gid=    given OLD=NEW, restore OLD gid as NEW gid
35 q,quiet     don't show progress meter
36 """
37
38 total_restored = 0
39
40 # stdout should be flushed after each line, even when not connected to a tty
41 sys.stdout.flush()
42 sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
43
44 def valid_restore_path(path):
45     path = os.path.normpath(path)
46     if path.startswith('/'):
47         path = path[1:]
48     if '/' in path:
49         return True
50
51 def parse_owner_mappings(type, options, fatal):
52     """Traverse the options and parse all --map-TYPEs, or call Option.fatal()."""
53     opt_name = '--map-' + type
54     value_rx = r'^([^=]+)=([^=]*)$'
55     if type in ('uid', 'gid'):
56         value_rx = r'^(-?[0-9]+)=(-?[0-9]+)$'
57     owner_map = {}
58     for flag in options:
59         (option, parameter) = flag
60         if option != opt_name:
61             continue
62         match = re.match(value_rx, parameter)
63         if not match:
64             raise fatal("couldn't parse %s as %s mapping" % (parameter, type))
65         old_id, new_id = match.groups()
66         if type in ('uid', 'gid'):
67             old_id = int(old_id)
68             new_id = int(new_id)
69         owner_map[old_id] = new_id
70     return owner_map
71
72 def apply_metadata(meta, name, restore_numeric_ids, owner_map):
73     m = copy.deepcopy(meta)
74     m.user = owner_map['user'].get(m.user, m.user)
75     m.group = owner_map['group'].get(m.group, m.group)
76     m.uid = owner_map['uid'].get(m.uid, m.uid)
77     m.gid = owner_map['gid'].get(m.gid, m.gid)
78     m.apply_to_path(name, restore_numeric_ids = restore_numeric_ids)
79     
80 def hardlink_compatible(prev_path, prev_item, new_item, top):
81     prev_candidate = top + prev_path
82     if not os.path.exists(prev_candidate):
83         return False
84     prev_meta, new_meta = prev_item.meta, new_item.meta
85     if new_item.oid != prev_item.oid \
86             or new_meta.mtime != prev_meta.mtime \
87             or new_meta.ctime != prev_meta.ctime \
88             or new_meta.mode != prev_meta.mode:
89         return False
90     # FIXME: should we be checking the path on disk, or the recorded metadata?
91     # The exists() above might seem to suggest the former.
92     if not new_meta.same_file(prev_meta):
93         return False
94     return True
95
96 def hardlink_if_possible(fullname, item, top, hardlinks):
97     """Find a suitable hardlink target, link to it, and return true,
98     otherwise return false."""
99     # The cwd will be dirname(fullname), and fullname will be
100     # absolute, i.e. /foo/bar, and the caller is expected to handle
101     # restoring the metadata if hardlinking isn't possible.
102
103     # FIXME: we can probably replace the target_vfs_path with the
104     # relevant vfs item
105     
106     # hardlinks tracks a list of (restore_path, vfs_path, meta)
107     # triples for each path we've written for a given hardlink_target.
108     # This allows us to handle the case where we restore a set of
109     # hardlinks out of order (with respect to the original save
110     # call(s)) -- i.e. when we don't restore the hardlink_target path
111     # first.  This data also allows us to attempt to handle other
112     # situations like hardlink sets that change on disk during a save,
113     # or between index and save.
114
115     target = item.meta.hardlink_target
116     assert(target)
117     assert(fullname.startswith('/'))
118     target_versions = hardlinks.get(target)
119     if target_versions:
120         # Check every path in the set that we've written so far for a match.
121         for prev_path, prev_item in target_versions:
122             if hardlink_compatible(prev_path, prev_item, item, top):
123                 try:
124                     os.link(top + prev_path, top + fullname)
125                     return True
126                 except OSError as e:
127                     if e.errno != errno.EXDEV:
128                         raise
129     else:
130         target_versions = []
131         hardlinks[target] = target_versions
132     target_versions.append((fullname, item))
133     return False
134
135 def write_file_content(repo, dest_path, vfs_file):
136     with vfs.fopen(repo, vfs_file) as inf:
137         with open(dest_path, 'wb') as outf:
138             for b in chunkyreader(inf):
139                 outf.write(b)
140
141 def write_file_content_sparsely(repo, dest_path, vfs_file):
142     with vfs.fopen(repo, vfs_file) as inf:
143         outfd = os.open(dest_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
144         try:
145             trailing_zeros = 0;
146             for b in chunkyreader(inf):
147                 trailing_zeros = write_sparsely(outfd, b, 512, trailing_zeros)
148             pos = os.lseek(outfd, trailing_zeros, os.SEEK_END)
149             os.ftruncate(outfd, pos)
150         finally:
151             os.close(outfd)
152             
153 def restore(repo, parent_path, name, item, top, sparse, numeric_ids, owner_map,
154             exclude_rxs, verbosity, hardlinks):
155     global total_restored
156     mode = vfs.item_mode(item)
157     treeish = S_ISDIR(mode)
158     fullname = parent_path + '/' + name
159     # Match behavior of index --exclude-rx with respect to paths.
160     if should_rx_exclude_path(fullname + ('/' if treeish else ''),
161                               exclude_rxs):
162         return
163
164     if not treeish:
165         # Do this now so we'll have meta.symlink_target for verbose output
166         item = vfs.augment_item_meta(repo, item, include_size=True)
167         meta = item.meta
168         assert(meta.mode == mode)
169
170     if stat.S_ISDIR(mode):
171         if verbosity >= 1:
172             print('%s/' % fullname)
173     elif stat.S_ISLNK(mode):
174         assert(meta.symlink_target)
175         if verbosity >= 2:
176             print('%s@ -> %s' % (fullname, meta.symlink_target))
177     else:
178         if verbosity >= 2:
179             print(fullname)
180
181     orig_cwd = os.getcwd()
182     try:
183         if treeish:
184             # Assumes contents() returns '.' with the full metadata first
185             sub_items = vfs.contents(repo, item, want_meta=True)
186             dot, item = next(sub_items, None)
187             assert(dot == '.')
188             item = vfs.augment_item_meta(repo, item, include_size=True)
189             meta = item.meta
190             meta.create_path(name)
191             os.chdir(name)
192             total_restored += 1
193             if verbosity >= 0:
194                 qprogress('Restoring: %d\r' % total_restored)
195             for sub_name, sub_item in sub_items:
196                 restore(repo, fullname, sub_name, sub_item, top, sparse,
197                         numeric_ids, owner_map, exclude_rxs, verbosity,
198                         hardlinks)
199             os.chdir('..')
200             apply_metadata(meta, name, numeric_ids, owner_map)
201         else:
202             created_hardlink = False
203             if meta.hardlink_target:
204                 created_hardlink = hardlink_if_possible(fullname, item, top,
205                                                         hardlinks)
206             if not created_hardlink:
207                 meta.create_path(name)
208                 if stat.S_ISREG(meta.mode):
209                     if sparse:
210                         write_file_content_sparsely(repo, name, item)
211                     else:
212                         write_file_content(repo, name, item)
213             total_restored += 1
214             if verbosity >= 0:
215                 qprogress('Restoring: %d\r' % total_restored)
216             if not created_hardlink:
217                 apply_metadata(meta, name, numeric_ids, owner_map)
218     finally:
219         os.chdir(orig_cwd)
220
221 def main():
222     o = options.Options(optspec)
223     opt, flags, extra = o.parse(sys.argv[1:])
224     verbosity = opt.verbose if not opt.quiet else -1
225     
226     git.check_repo_or_die()
227
228     if not extra:
229         o.fatal('must specify at least one filename to restore')
230
231     exclude_rxs = parse_rx_excludes(flags, o.fatal)
232
233     owner_map = {}
234     for map_type in ('user', 'group', 'uid', 'gid'):
235         owner_map[map_type] = parse_owner_mappings(map_type, flags, o.fatal)
236
237     if opt.outdir:
238         mkdirp(opt.outdir)
239         os.chdir(opt.outdir)
240
241     repo = RemoteRepo(opt.remote) if opt.remote else LocalRepo()
242     top = os.getcwd()
243     hardlinks = {}
244     for path in extra:
245         if not valid_restore_path(path):
246             add_error("path %r doesn't include a branch and revision" % path)
247             continue
248         try:
249             resolved = vfs.lresolve(repo, path, want_meta=True)
250         except vfs.IOError as e:
251             add_error(e)
252             continue
253         path_parent, path_name = os.path.split(path)
254         leaf_name, leaf_item = resolved[-1]
255         if not leaf_item:
256             add_error('error: cannot access %r in %r'
257                       % ('/'.join(name for name, item in resolved),
258                          path))
259             continue
260         if not path_name or path_name == '.':
261             # Source is /foo/what/ever/ or /foo/what/ever/. -- extract
262             # what/ever/* to the current directory, and if name == '.'
263             # (i.e. /foo/what/ever/.), then also restore what/ever's
264             # metadata to the current directory.
265             treeish = vfs.item_mode(leaf_item)
266             if not treeish:
267                 add_error('%r cannot be restored as a directory' % path)
268             else:
269                 items = vfs.contents(repo, leaf_item, want_meta=True)
270                 dot, leaf_item = next(items, None)
271                 assert(dot == '.')
272                 for sub_name, sub_item in items:
273                     restore(repo, '', sub_name, sub_item, top,
274                             opt.sparse, opt.numeric_ids, owner_map,
275                             exclude_rxs, verbosity, hardlinks)
276                 if path_name == '.':
277                     leaf_item = vfs.augment_item_meta(repo, leaf_item,
278                                                       include_size=True)
279                     apply_metadata(leaf_item.meta, '.',
280                                    opt.numeric_ids, owner_map)
281         else:
282             restore(repo, '', leaf_name, leaf_item, top,
283                     opt.sparse, opt.numeric_ids, owner_map,
284                     exclude_rxs, verbosity, hardlinks)
285
286     if verbosity >= 0:
287         progress('Restoring: %d, done.\n' % total_restored)
288     die_if_errors()
289
290 wrap_main(main)