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