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