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