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