]> arthur.barton.de Git - bup.git/blob - cmd/restore-cmd.py
9fbaf909ee14eeac5553e0b9f648f303dfecd819
[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 import copy, errno, os, sys, stat, re
9
10 from bup import options, git, metadata, vfs
11 from bup._helpers import write_sparsely
12 from bup.helpers import (add_error, chunkyreader, handle_ctrl_c, log, mkdirp,
13                          parse_rx_excludes, progress, qprogress, saved_errors,
14                          should_rx_exclude_path, unlink)
15
16
17 optspec = """
18 bup restore [-C outdir] </branch/revision/path/to/dir ...>
19 --
20 C,outdir=   change to given outdir before extracting files
21 numeric-ids restore numeric IDs (user, group, etc.) rather than names
22 exclude-rx= skip paths matching the unanchored regex (may be repeated)
23 exclude-rx-from= skip --exclude-rx patterns in file (may be repeated)
24 sparse      create sparse files
25 v,verbose   increase log output (can be used more than once)
26 map-user=   given OLD=NEW, restore OLD user as NEW user
27 map-group=  given OLD=NEW, restore OLD group as NEW group
28 map-uid=    given OLD=NEW, restore OLD uid as NEW uid
29 map-gid=    given OLD=NEW, restore OLD gid as NEW gid
30 q,quiet     don't show progress meter
31 """
32
33 total_restored = 0
34
35 # stdout should be flushed after each line, even when not connected to a tty
36 sys.stdout.flush()
37 sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
38
39 def verbose1(s):
40     if opt.verbose >= 1:
41         print s
42
43
44 def verbose2(s):
45     if opt.verbose >= 2:
46         print s
47
48
49 def plog(s):
50     if opt.quiet:
51         return
52     qprogress(s)
53
54
55 def valid_restore_path(path):
56     path = os.path.normpath(path)
57     if path.startswith('/'):
58         path = path[1:]
59     if '/' in path:
60         return True
61
62
63 def print_info(n, fullname):
64     if stat.S_ISDIR(n.mode):
65         verbose1('%s/' % fullname)
66     elif stat.S_ISLNK(n.mode):
67         verbose2('%s@ -> %s' % (fullname, n.readlink()))
68     else:
69         verbose2(fullname)
70
71
72 def create_path(n, fullname, meta):
73     if meta:
74         meta.create_path(fullname)
75     else:
76         # These fallbacks are important -- meta could be null if, for
77         # example, save created a "fake" item, i.e. a new strip/graft
78         # path element, etc.  You can find cases like that by
79         # searching for "Metadata()".
80         unlink(fullname)
81         if stat.S_ISDIR(n.mode):
82             mkdirp(fullname)
83         elif stat.S_ISLNK(n.mode):
84             os.symlink(n.readlink(), fullname)
85
86
87 def parse_owner_mappings(type, options, fatal):
88     """Traverse the options and parse all --map-TYPEs, or call Option.fatal()."""
89     opt_name = '--map-' + type
90     value_rx = r'^([^=]+)=([^=]*)$'
91     if type in ('uid', 'gid'):
92         value_rx = r'^(-?[0-9]+)=(-?[0-9]+)$'
93     owner_map = {}
94     for flag in options:
95         (option, parameter) = flag
96         if option != opt_name:
97             continue
98         match = re.match(value_rx, parameter)
99         if not match:
100             raise fatal("couldn't parse %s as %s mapping" % (parameter, type))
101         old_id, new_id = match.groups()
102         if type in ('uid', 'gid'):
103             old_id = int(old_id)
104             new_id = int(new_id)
105         owner_map[old_id] = new_id
106     return owner_map
107
108
109 def apply_metadata(meta, name, restore_numeric_ids, owner_map):
110     m = copy.deepcopy(meta)
111     m.user = owner_map['user'].get(m.user, m.user)
112     m.group = owner_map['group'].get(m.group, m.group)
113     m.uid = owner_map['uid'].get(m.uid, m.uid)
114     m.gid = owner_map['gid'].get(m.gid, m.gid)
115     m.apply_to_path(name, restore_numeric_ids = restore_numeric_ids)
116
117
118 # Track a list of (restore_path, vfs_path, meta) triples for each path
119 # we've written for a given hardlink_target.  This allows us to handle
120 # the case where we restore a set of hardlinks out of order (with
121 # respect to the original save call(s)) -- i.e. when we don't restore
122 # the hardlink_target path first.  This data also allows us to attempt
123 # to handle other situations like hardlink sets that change on disk
124 # during a save, or between index and save.
125 targets_written = {}
126
127 def hardlink_compatible(target_path, target_vfs_path, target_meta,
128                         src_node, src_meta):
129     global top
130     if not os.path.exists(target_path):
131         return False
132     target_node = top.lresolve(target_vfs_path)
133     if src_node.mode != target_node.mode \
134             or src_node.mtime != target_node.mtime \
135             or src_node.ctime != target_node.ctime \
136             or src_node.hash != target_node.hash:
137         return False
138     if not src_meta.same_file(target_meta):
139         return False
140     return True
141
142
143 def hardlink_if_possible(fullname, node, meta):
144     """Find a suitable hardlink target, link to it, and return true,
145     otherwise return false."""
146     # Expect the caller to handle restoring the metadata if
147     # hardlinking isn't possible.
148     global targets_written
149     target = meta.hardlink_target
150     target_versions = targets_written.get(target)
151     if target_versions:
152         # Check every path in the set that we've written so far for a match.
153         for (target_path, target_vfs_path, target_meta) in target_versions:
154             if hardlink_compatible(target_path, target_vfs_path, target_meta,
155                                    node, meta):
156                 try:
157                     os.link(target_path, fullname)
158                     return True
159                 except OSError as e:
160                     if e.errno != errno.EXDEV:
161                         raise
162     else:
163         target_versions = []
164         targets_written[target] = target_versions
165     full_vfs_path = node.fullname()
166     target_versions.append((fullname, full_vfs_path, meta))
167     return False
168
169
170 def write_file_content(fullname, n):
171     outf = open(fullname, 'wb')
172     try:
173         for b in chunkyreader(n.open()):
174             outf.write(b)
175     finally:
176         outf.close()
177
178
179 def write_file_content_sparsely(fullname, n):
180     outfd = os.open(fullname, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
181     try:
182         trailing_zeros = 0;
183         for b in chunkyreader(n.open()):
184             trailing_zeros = write_sparsely(outfd, b, 512, trailing_zeros)
185         pos = os.lseek(outfd, trailing_zeros, os.SEEK_END)
186         os.ftruncate(outfd, pos)
187     finally:
188         os.close(outfd)
189
190
191 def find_dir_item_metadata_by_name(dir, name):
192     """Find metadata in dir (a node) for an item with the given name,
193     or for the directory itself if the name is ''."""
194     meta_stream = None
195     try:
196         mfile = dir.metadata_file() # VFS file -- cannot close().
197         if mfile:
198             meta_stream = mfile.open()
199             # First entry is for the dir itself.
200             meta = metadata.Metadata.read(meta_stream)
201             if name == '':
202                 return meta
203             for sub in dir:
204                 if stat.S_ISDIR(sub.mode):
205                     meta = find_dir_item_metadata_by_name(sub, '')
206                 else:
207                     meta = metadata.Metadata.read(meta_stream)
208                 if sub.name == name:
209                     return meta
210     finally:
211         if meta_stream:
212             meta_stream.close()
213
214
215 def do_root(n, sparse, owner_map, restore_root_meta = True):
216     # Very similar to do_node(), except that this function doesn't
217     # create a path for n's destination directory (and so ignores
218     # n.fullname).  It assumes the destination is '.', and restores
219     # n's metadata and content there.
220     global total_restored, opt
221     meta_stream = None
222     try:
223         # Directory metadata is the first entry in any .bupm file in
224         # the directory.  Get it.
225         mfile = n.metadata_file() # VFS file -- cannot close().
226         root_meta = None
227         if mfile:
228             meta_stream = mfile.open()
229             root_meta = metadata.Metadata.read(meta_stream)
230         print_info(n, '.')
231         total_restored += 1
232         plog('Restoring: %d\r' % total_restored)
233         for sub in n:
234             m = None
235             # Don't get metadata if this is a dir -- handled in sub do_node().
236             if meta_stream and not stat.S_ISDIR(sub.mode):
237                 m = metadata.Metadata.read(meta_stream)
238             do_node(n, sub, sparse, owner_map, meta = m)
239         if root_meta and restore_root_meta:
240             apply_metadata(root_meta, '.', opt.numeric_ids, owner_map)
241     finally:
242         if meta_stream:
243             meta_stream.close()
244
245 def do_node(top, n, sparse, owner_map, meta = None):
246     # Create n.fullname(), relative to the current directory, and
247     # restore all of its metadata, when available.  The meta argument
248     # will be None for dirs, or when there is no .bupm (i.e. no
249     # metadata).
250     global total_restored, opt
251     meta_stream = None
252     write_content = sparse and write_file_content_sparsely or write_file_content
253     try:
254         fullname = n.fullname(stop_at=top)
255         # Match behavior of index --exclude-rx with respect to paths.
256         exclude_candidate = '/' + fullname
257         if(stat.S_ISDIR(n.mode)):
258             exclude_candidate += '/'
259         if should_rx_exclude_path(exclude_candidate, exclude_rxs):
260             return
261         # If this is a directory, its metadata is the first entry in
262         # any .bupm file inside the directory.  Get it.
263         if(stat.S_ISDIR(n.mode)):
264             mfile = n.metadata_file() # VFS file -- cannot close().
265             if mfile:
266                 meta_stream = mfile.open()
267                 meta = metadata.Metadata.read(meta_stream)
268         print_info(n, fullname)
269
270         created_hardlink = False
271         if meta and meta.hardlink_target:
272             created_hardlink = hardlink_if_possible(fullname, n, meta)
273
274         if not created_hardlink:
275             create_path(n, fullname, meta)
276             if meta:
277                 if stat.S_ISREG(meta.mode):
278                     write_content(fullname, n)
279             elif stat.S_ISREG(n.mode):
280                 write_content(fullname, n)
281
282         total_restored += 1
283         plog('Restoring: %d\r' % total_restored)
284         for sub in n:
285             m = None
286             # Don't get metadata if this is a dir -- handled in sub do_node().
287             if meta_stream and not stat.S_ISDIR(sub.mode):
288                 m = metadata.Metadata.read(meta_stream)
289             do_node(top, sub, sparse, owner_map, meta = m)
290         if meta and not created_hardlink:
291             apply_metadata(meta, fullname, opt.numeric_ids, owner_map)
292     finally:
293         if meta_stream:
294             meta_stream.close()
295         n.release()
296
297
298 handle_ctrl_c()
299
300 o = options.Options(optspec)
301 (opt, flags, extra) = o.parse(sys.argv[1:])
302
303 git.check_repo_or_die()
304 top = vfs.RefList(None)
305
306 if not extra:
307     o.fatal('must specify at least one filename to restore')
308     
309 exclude_rxs = parse_rx_excludes(flags, o.fatal)
310
311 owner_map = {}
312 for map_type in ('user', 'group', 'uid', 'gid'):
313     owner_map[map_type] = parse_owner_mappings(map_type, flags, o.fatal)
314
315 if opt.outdir:
316     mkdirp(opt.outdir)
317     os.chdir(opt.outdir)
318
319 ret = 0
320 for d in extra:
321     if not valid_restore_path(d):
322         add_error("ERROR: path %r doesn't include a branch and revision" % d)
323         continue
324     path,name = os.path.split(d)
325     try:
326         n = top.lresolve(d)
327     except vfs.NodeError as e:
328         add_error(e)
329         continue
330     isdir = stat.S_ISDIR(n.mode)
331     if not name or name == '.':
332         # Source is /foo/what/ever/ or /foo/what/ever/. -- extract
333         # what/ever/* to the current directory, and if name == '.'
334         # (i.e. /foo/what/ever/.), then also restore what/ever's
335         # metadata to the current directory.
336         if not isdir:
337             add_error('%r: not a directory' % d)
338         else:
339             do_root(n, opt.sparse, owner_map, restore_root_meta = (name == '.'))
340     else:
341         # Source is /foo/what/ever -- extract ./ever to cwd.
342         if isinstance(n, vfs.FakeSymlink):
343             # Source is actually /foo/what, i.e. a top-level commit
344             # like /foo/latest, which is a symlink to ../.commit/SHA.
345             # So dereference it, and restore ../.commit/SHA/. to
346             # "./what/.".
347             target = n.dereference()
348             mkdirp(n.name)
349             os.chdir(n.name)
350             do_root(target, opt.sparse, owner_map)
351         else: # Not a directory or fake symlink.
352             meta = find_dir_item_metadata_by_name(n.parent, n.name)
353             do_node(n.parent, n, opt.sparse, owner_map, meta = meta)
354
355 if not opt.quiet:
356     progress('Restoring: %d, done.\n' % total_restored)
357
358 if saved_errors:
359     log('WARNING: %d errors encountered while restoring.\n' % len(saved_errors))
360     sys.exit(1)