3 # https://sourceware.org/bugzilla/show_bug.cgi?id=26034
4 export "BUP_ARGV_0"="$0"
7 export "BUP_ARGV_${arg_i}"="$arg"
11 # Here to end of preamble replaced during install
12 bup_python="$(dirname "$0")/../../config/bin/python" || exit $?
13 exec "$bup_python" "$0"
17 from __future__ import absolute_import
18 from stat import S_ISDIR
19 import copy, errno, os, re, stat, sys
21 sys.path[:0] = [os.path.dirname(os.path.realpath(__file__)) + '/..']
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
34 bup restore [-r host:path] [-C outdir] </branch/revision/path/to/dir ...>
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
52 # stdout should be flushed after each line, even when not connected to a tty
53 stdoutfd = sys.stdout.fileno()
55 sys.stdout = os.fdopen(stdoutfd, 'w', 1)
56 out = byte_stream(sys.stdout)
58 def valid_restore_path(path):
59 path = os.path.normpath(path)
60 if path.startswith(b'/'):
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]+)$')
71 value_rx = re.compile(br'^([^=]+)=([^=]*)$')
74 (option, parameter) = flag
75 if option != opt_name:
77 parameter = argv_bytes(parameter)
78 match = value_rx.match(parameter)
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'):
85 owner_map[old_id] = new_id
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)
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):
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:
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):
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.
119 # FIXME: we can probably replace the target_vfs_path with the
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.
131 target = item.meta.hardlink_target
133 assert(fullname.startswith(b'/'))
134 target_versions = hardlinks.get(target)
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):
140 os.link(top + prev_path, top + fullname)
143 if e.errno != errno.EXDEV:
147 hardlinks[target] = target_versions
148 target_versions.append((fullname, item))
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):
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)
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)
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''),
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)
184 assert(meta.mode == mode)
186 if stat.S_ISDIR(mode):
188 out.write(b'%s/\n' % fullname)
189 elif stat.S_ISLNK(mode):
190 assert(meta.symlink_target)
192 out.write(b'%s@ -> %s\n' % (fullname, meta.symlink_target))
195 out.write(fullname + '\n')
197 orig_cwd = os.getcwd()
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)
204 item = vfs.augment_item_meta(repo, item, include_size=True)
206 meta.create_path(name)
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,
216 apply_metadata(meta, name, numeric_ids, owner_map)
218 created_hardlink = False
219 if meta.hardlink_target:
220 created_hardlink = hardlink_if_possible(fullname, item, top,
222 if not created_hardlink:
223 meta.create_path(name)
224 if stat.S_ISREG(meta.mode):
226 write_file_content_sparsely(repo, name, item)
228 write_file_content(repo, name, item)
231 qprogress('Restoring: %d\r' % total_restored)
232 if not created_hardlink:
233 apply_metadata(meta, name, numeric_ids, owner_map)
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
242 opt.remote = argv_bytes(opt.remote)
244 opt.outdir = argv_bytes(opt.outdir)
246 git.check_repo_or_die()
249 o.fatal('must specify at least one filename to restore')
251 exclude_rxs = parse_rx_excludes(flags, o.fatal)
254 for map_type in ('user', 'group', 'uid', 'gid'):
255 owner_map[map_type] = parse_owner_mappings(map_type, flags, o.fatal)
261 repo = RemoteRepo(opt.remote) if opt.remote else LocalRepo()
262 top = fsencode(os.getcwd())
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)
269 resolved = vfs.resolve(repo, path, want_meta=True, follow=False)
270 except vfs.IOError as e:
273 if len(resolved) == 3 and resolved[2][0] == b'latest':
274 # Follow latest symlink to the actual save
276 resolved = vfs.resolve(repo, b'latest', parent=resolved[:-1],
278 except vfs.IOError as e:
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]
287 add_error('error: cannot access %r in %r'
288 % ('/'.join(name for name, item in resolved),
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)
298 add_error('%r cannot be restored as a directory' % path)
300 items = vfs.contents(repo, leaf_item, want_meta=True)
301 dot, leaf_item = next(items, None)
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,
310 apply_metadata(leaf_item.meta, b'.',
311 opt.numeric_ids, owner_map)
313 restore(repo, b'', leaf_name, leaf_item, top,
314 opt.sparse, opt.numeric_ids, owner_map,
315 exclude_rxs, verbosity, hardlinks)
318 progress('Restoring: %d, done.\n' % total_restored)