]> arthur.barton.de Git - bup.git/blob - lib/bup/vfs.py
Merge remote branch 'origin/master'
[bup.git] / lib / bup / vfs.py
1 """Virtual File System representing bup's repository contents.
2
3 The vfs.py library makes it possible to expose contents from bup's repository
4 and abstracts internal name mangling and storage from the exposition layer.
5 """
6 import os, re, stat, time
7 from bup import git
8 from helpers import *
9
10 EMPTY_SHA='\0'*20
11
12 _cp = None
13 def cp():
14     """Create a git.CatPipe object or reuse the already existing one."""
15     global _cp
16     if not _cp:
17         _cp = git.CatPipe()
18     return _cp
19
20 class NodeError(Exception):
21     """VFS base exception."""
22     pass
23
24 class NoSuchFile(NodeError):
25     """Request of a file that does not exist."""
26     pass
27
28 class NotDir(NodeError):
29     """Attempt to do a directory action on a file that is not one."""
30     pass
31
32 class NotFile(NodeError):
33     """Access to a node that does not represent a file."""
34     pass
35
36 class TooManySymlinks(NodeError):
37     """Symlink dereferencing level is too deep."""
38     pass
39
40
41 def _treeget(hash):
42     it = cp().get(hash.encode('hex'))
43     type = it.next()
44     assert(type == 'tree')
45     return git.treeparse(''.join(it))
46
47
48 def _tree_decode(hash):
49     tree = [(int(name,16),stat.S_ISDIR(int(mode,8)),sha)
50             for (mode,name,sha)
51             in _treeget(hash)]
52     assert(tree == list(sorted(tree)))
53     return tree
54
55
56 def _chunk_len(hash):
57     return sum(len(b) for b in cp().join(hash.encode('hex')))
58
59
60 def _last_chunk_info(hash):
61     tree = _tree_decode(hash)
62     assert(tree)
63     (ofs,isdir,sha) = tree[-1]
64     if isdir:
65         (subofs, sublen) = _last_chunk_info(sha)
66         return (ofs+subofs, sublen)
67     else:
68         return (ofs, _chunk_len(sha))
69
70
71 def _total_size(hash):
72     (lastofs, lastsize) = _last_chunk_info(hash)
73     return lastofs + lastsize
74
75
76 def _chunkiter(hash, startofs):
77     assert(startofs >= 0)
78     tree = _tree_decode(hash)
79
80     # skip elements before startofs
81     for i in xrange(len(tree)):
82         if i+1 >= len(tree) or tree[i+1][0] > startofs:
83             break
84     first = i
85
86     # iterate through what's left
87     for i in xrange(first, len(tree)):
88         (ofs,isdir,sha) = tree[i]
89         skipmore = startofs-ofs
90         if skipmore < 0:
91             skipmore = 0
92         if isdir:
93             for b in _chunkiter(sha, skipmore):
94                 yield b
95         else:
96             yield ''.join(cp().join(sha.encode('hex')))[skipmore:]
97
98
99 class _ChunkReader(object):
100     def __init__(self, hash, isdir, startofs):
101         if isdir:
102             self.it = _chunkiter(hash, startofs)
103             self.blob = None
104         else:
105             self.it = None
106             self.blob = ''.join(cp().join(hash.encode('hex')))[startofs:]
107         self.ofs = startofs
108
109     def next(self, size):
110         out = ''
111         while len(out) < size:
112             if self.it and not self.blob:
113                 try:
114                     self.blob = self.it.next()
115                 except StopIteration:
116                     self.it = None
117             if self.blob:
118                 want = size - len(out)
119                 out += self.blob[:want]
120                 self.blob = self.blob[want:]
121             if not self.it:
122                 break
123         debug2('next(%d) returned %d\n' % (size, len(out)))
124         self.ofs += len(out)
125         return out
126
127
128 class _FileReader(object):
129     def __init__(self, hash, size, isdir):
130         self.hash = hash
131         self.ofs = 0
132         self.size = size
133         self.isdir = isdir
134         self.reader = None
135
136     def seek(self, ofs):
137         if ofs > self.size:
138             self.ofs = self.size
139         elif ofs < 0:
140             self.ofs = 0
141         else:
142             self.ofs = ofs
143
144     def tell(self):
145         return self.ofs
146
147     def read(self, count = -1):
148         if count < 0:
149             count = self.size - self.ofs
150         if not self.reader or self.reader.ofs != self.ofs:
151             self.reader = _ChunkReader(self.hash, self.isdir, self.ofs)
152         try:
153             buf = self.reader.next(count)
154         except:
155             self.reader = None
156             raise  # our offsets will be all screwed up otherwise
157         self.ofs += len(buf)
158         return buf
159
160     def close(self):
161         pass
162
163
164 class Node(object):
165     """Base class for file representation."""
166     def __init__(self, parent, name, mode, hash):
167         self.parent = parent
168         self.name = name
169         self.mode = mode
170         self.hash = hash
171         self.ctime = self.mtime = self.atime = 0
172         self._subs = None
173
174     def __cmp__(a, b):
175         return cmp(a and a.name or None, b and b.name or None)
176
177     def __iter__(self):
178         return iter(self.subs())
179
180     def fullname(self, stop_at=None):
181         """Get this file's full path."""
182         assert(self != stop_at)  # would be the empty string; too weird
183         if self.parent and self.parent != stop_at:
184             return os.path.join(self.parent.fullname(stop_at=stop_at),
185                                 self.name)
186         else:
187             return self.name
188
189     def _mksubs(self):
190         self._subs = {}
191
192     def subs(self):
193         """Get a list of nodes that are contained in this node."""
194         if self._subs == None:
195             self._mksubs()
196         return sorted(self._subs.values())
197
198     def sub(self, name):
199         """Get node named 'name' that is contained in this node."""
200         if self._subs == None:
201             self._mksubs()
202         ret = self._subs.get(name)
203         if not ret:
204             raise NoSuchFile("no file %r in %r" % (name, self.name))
205         return ret
206
207     def top(self):
208         """Return the very top node of the tree."""
209         if self.parent:
210             return self.parent.top()
211         else:
212             return self
213
214     def fs_top(self):
215         """Return the top node of the particular backup set.
216
217         If this node isn't inside a backup set, return the root level.
218         """
219         if self.parent and not isinstance(self.parent, CommitList):
220             return self.parent.fs_top()
221         else:
222             return self
223
224     def _lresolve(self, parts):
225         #debug2('_lresolve %r in %r\n' % (parts, self.name))
226         if not parts:
227             return self
228         (first, rest) = (parts[0], parts[1:])
229         if first == '.':
230             return self._lresolve(rest)
231         elif first == '..':
232             if not self.parent:
233                 raise NoSuchFile("no parent dir for %r" % self.name)
234             return self.parent._lresolve(rest)
235         elif rest:
236             return self.sub(first)._lresolve(rest)
237         else:
238             return self.sub(first)
239
240     def lresolve(self, path, stay_inside_fs=False):
241         """Walk into a given sub-path of this node.
242
243         If the last element is a symlink, leave it as a symlink, don't resolve
244         it.  (like lstat())
245         """
246         start = self
247         if not path:
248             return start
249         if path.startswith('/'):
250             if stay_inside_fs:
251                 start = self.fs_top()
252             else:
253                 start = self.top()
254             path = path[1:]
255         parts = re.split(r'/+', path or '.')
256         if not parts[-1]:
257             parts[-1] = '.'
258         #debug2('parts: %r %r\n' % (path, parts))
259         return start._lresolve(parts)
260
261     def resolve(self, path = ''):
262         """Like lresolve(), and dereference it if it was a symlink."""
263         return self.lresolve(path).lresolve('.')
264
265     def try_resolve(self, path = ''):
266         """Like resolve(), but don't worry if a symlink uses an invalid path.
267
268         Returns an error if any intermediate nodes were invalid.
269         """
270         n = self.lresolve(path)
271         try:
272             n = n.lresolve('.')
273         except NoSuchFile:
274             pass
275         return n
276
277     def nlinks(self):
278         """Get the number of hard links to the current node."""
279         if self._subs == None:
280             self._mksubs()
281         return 1
282
283     def size(self):
284         """Get the size of the current node."""
285         return 0
286
287     def open(self):
288         """Open the current node. It is an error to open a non-file node."""
289         raise NotFile('%s is not a regular file' % self.name)
290
291
292 class File(Node):
293     """A normal file from bup's repository."""
294     def __init__(self, parent, name, mode, hash, bupmode):
295         Node.__init__(self, parent, name, mode, hash)
296         self.bupmode = bupmode
297         self._cached_size = None
298         self._filereader = None
299
300     def open(self):
301         """Open the file."""
302         # You'd think FUSE might call this only once each time a file is
303         # opened, but no; it's really more of a refcount, and it's called
304         # once per read().  Thus, it's important to cache the filereader
305         # object here so we're not constantly re-seeking.
306         if not self._filereader:
307             self._filereader = _FileReader(self.hash, self.size(),
308                                            self.bupmode == git.BUP_CHUNKED)
309         self._filereader.seek(0)
310         return self._filereader
311
312     def size(self):
313         """Get this file's size."""
314         if self._cached_size == None:
315             debug1('<<<<File.size() is calculating (for %r)...\n' % self.name)
316             if self.bupmode == git.BUP_CHUNKED:
317                 self._cached_size = _total_size(self.hash)
318             else:
319                 self._cached_size = _chunk_len(self.hash)
320             debug1('<<<<File.size() done.\n')
321         return self._cached_size
322
323
324 _symrefs = 0
325 class Symlink(File):
326     """A symbolic link from bup's repository."""
327     def __init__(self, parent, name, hash, bupmode):
328         File.__init__(self, parent, name, 0120000, hash, bupmode)
329
330     def size(self):
331         """Get the file size of the file at which this link points."""
332         return len(self.readlink())
333
334     def readlink(self):
335         """Get the path that this link points at."""
336         return ''.join(cp().join(self.hash.encode('hex')))
337
338     def dereference(self):
339         """Get the node that this link points at.
340
341         If the path is invalid, raise a NoSuchFile exception. If the level of
342         indirection of symlinks is 100 levels deep, raise a TooManySymlinks
343         exception.
344         """
345         global _symrefs
346         if _symrefs > 100:
347             raise TooManySymlinks('too many levels of symlinks: %r'
348                                   % self.fullname())
349         _symrefs += 1
350         try:
351             try:
352                 return self.parent.lresolve(self.readlink(),
353                                             stay_inside_fs=True)
354             except NoSuchFile:
355                 raise NoSuchFile("%s: broken symlink to %r"
356                                  % (self.fullname(), self.readlink()))
357         finally:
358             _symrefs -= 1
359
360     def _lresolve(self, parts):
361         return self.dereference()._lresolve(parts)
362
363
364 class FakeSymlink(Symlink):
365     """A symlink that is not stored in the bup repository."""
366     def __init__(self, parent, name, toname):
367         Symlink.__init__(self, parent, name, EMPTY_SHA, git.BUP_NORMAL)
368         self.toname = toname
369
370     def readlink(self):
371         """Get the path that this link points at."""
372         return self.toname
373
374
375 class Dir(Node):
376     """A directory stored inside of bup's repository."""
377     def _mksubs(self):
378         self._subs = {}
379         it = cp().get(self.hash.encode('hex'))
380         type = it.next()
381         if type == 'commit':
382             del it
383             it = cp().get(self.hash.encode('hex') + ':')
384             type = it.next()
385         assert(type == 'tree')
386         for (mode,mangled_name,sha) in git.treeparse(''.join(it)):
387             mode = int(mode, 8)
388             name = mangled_name
389             (name,bupmode) = git.demangle_name(mangled_name)
390             if bupmode == git.BUP_CHUNKED:
391                 mode = 0100644
392             if stat.S_ISDIR(mode):
393                 self._subs[name] = Dir(self, name, mode, sha)
394             elif stat.S_ISLNK(mode):
395                 self._subs[name] = Symlink(self, name, sha, bupmode)
396             else:
397                 self._subs[name] = File(self, name, mode, sha, bupmode)
398
399
400 class CommitList(Node):
401     """A reverse-chronological list of commits on a branch in bup's repository.
402
403     Represents each commit as a directory and a symlink that points to the
404     directory. The symlink is named after the date. Prepends a dot to each hash
405     to make commits look like hidden directories.
406     """
407     def __init__(self, parent, name, hash):
408         Node.__init__(self, parent, name, 040000, hash)
409
410     def _mksubs(self):
411         self._subs = {}
412         revs = list(git.rev_list(self.hash.encode('hex')))
413         for (date, commit) in revs:
414             l = time.localtime(date)
415             ls = time.strftime('%Y-%m-%d-%H%M%S', l)
416             commithex = '.' + commit.encode('hex')
417             n1 = Dir(self, commithex, 040000, commit)
418             n2 = FakeSymlink(self, ls, commithex)
419             n1.ctime = n1.mtime = n2.ctime = n2.mtime = date
420             self._subs[commithex] = n1
421             self._subs[ls] = n2
422             latest = max(revs)
423         if latest:
424             (date, commit) = latest
425             commithex = '.' + commit.encode('hex')
426             n2 = FakeSymlink(self, 'latest', commithex)
427             n2.ctime = n2.mtime = date
428             self._subs['latest'] = n2
429
430
431 class RefList(Node):
432     """A list of branches in bup's repository.
433
434     The sub-nodes of the ref list are a series of CommitList for each commit
435     hash pointed to by a branch.
436     """
437     def __init__(self, parent):
438         Node.__init__(self, parent, '/', 040000, EMPTY_SHA)
439
440     def _mksubs(self):
441         self._subs = {}
442         for (name,sha) in git.list_refs():
443             if name.startswith('refs/heads/'):
444                 name = name[11:]
445                 date = git.rev_get_date(sha.encode('hex'))
446                 n1 = CommitList(self, name, sha)
447                 n1.ctime = n1.mtime = date
448                 self._subs[name] = n1
449
450