Repository: borgbackup/borg

Issue:
fuse: "Invalid argument" OSError with negative number for seek (-o versions)
I have tried to use the `-o versions` option with `borg mount`, but got the following error:

```
Local Exception
Traceback (most recent call last):
  File "/usr/lib/python3.5/site-packages/borg/archiver.py", line 2436, in main
    exit_code = archiver.run(args)
  File "/usr/lib/python3.5/site-packages/borg/archiver.py", line 2353, in run
    return args.func(args)
  File "/usr/lib/python3.5/site-packages/borg/archiver.py", line 98, in wrapper
    return method(self, args, repository=repository, **kwargs)
  File "/usr/lib/python3.5/site-packages/borg/archiver.py", line 799, in do_mount
    operations.mount(args.mountpoint, args.options, args.foreground)
  File "/usr/lib/python3.5/site-packages/borg/fuse.py", line 108, in mount
    self._create_filesystem()
  File "/usr/lib/python3.5/site-packages/borg/fuse.py", line 86, in _create_filesystem
    self.process_archive(archive)
  File "/usr/lib/python3.5/site-packages/borg/fuse.py", line 159, in process_archive
    self.process_leaf(segments[-1], item, parent, prefix, is_dir)
  File "/usr/lib/python3.5/site-packages/borg/fuse.py", line 176, in process_leaf
    item = self.cache.get(inode)
  File "/usr/lib/python3.5/site-packages/borg/fuse.py", line 46, in get
    self.fd.seek(inode - self.offset, io.SEEK_SET)
OSError: [Errno 22] Invalid argument

Platform: Linux lena.thequod.de 4.7.2-1-ARCH #1 SMP PREEMPT Sat Aug 20 23:02:56 CEST 2016 x86_64 
Linux: arch Arch Linux
Borg: 1.1.0b2.dev35+ng783d01f  Python: CPython 3.5.2
PID: 16776  CWD: /home/user
sys.argv: ['/usr/bin/borg', 'mount', '-o', 'versions', 'tmp/borg-backup', 'tmp/mnt']
SSH_ORIGINAL_COMMAND: None
```

The problem is that `inode - self.offset` is negative here:

```
> /usr/lib/python3.5/site-packages/borg/fuse.py(48)get()
-> self.fd.seek(inode - self.offset, io.SEEK_SET)
(Pdb) inode
16069
(Pdb) self.offset
1000000
(Pdb) io.SEEK_SET
0
(Pdb) self.fd
<_io.BufferedRandom name=4>
(Pdb) self.fd.seek(inode - self.offset, io.SEEK_SET)
*** OSError: [Errno 22] Invalid argument
(Pdb) 
```



Discussion:
What does the (anonymized) command line look like used for creating the archive in question? (borg info)

@enkore 
What do you mean?
I am running it on a repo here..  and `borg info` does not say much for it.

Ah, alright my bad.

I think i have the reason why it malfunctions. It is related to the modified filenames for versioning and finding hardlink masters (which it tries with the unmodified name). I'll try to fix it...


Resolving patch:
diff --git a/src/borg/fuse.py b/src/borg/fuse.py
--- a/src/borg/fuse.py
+++ b/src/borg/fuse.py
@@ -15,7 +15,7 @@
 logger = create_logger()
 
 from .archive import Archive
-from .helpers import daemonize, safe_encode
+from .helpers import daemonize
 from .item import Item
 from .lrucache import LRUCache
 
@@ -43,7 +43,10 @@ def add(self, item):
         return pos + self.offset
 
     def get(self, inode):
-        self.fd.seek(inode - self.offset, io.SEEK_SET)
+        offset = inode - self.offset
+        if offset < 0:
+            raise ValueError('ItemCache.get() called with an invalid inode number')
+        self.fd.seek(offset, io.SEEK_SET)
         item = next(msgpack.Unpacker(self.fd, read_size=1024))
         return Item(internal_dict=item)
 
@@ -133,46 +136,66 @@ def _create_dir(self, parent):
     def process_archive(self, archive, prefix=[]):
         """Build fuse inode hierarchy from archive metadata
         """
+        self.file_versions = {}  # for versions mode: original path -> version
         unpacker = msgpack.Unpacker()
         for key, chunk in zip(archive.metadata.items, self.repository.get_many(archive.metadata.items)):
             _, data = self.key.decrypt(key, chunk)
             unpacker.feed(data)
             for item in unpacker:
                 item = Item(internal_dict=item)
+                path = os.fsencode(os.path.normpath(item.path))
                 is_dir = stat.S_ISDIR(item.mode)
                 if is_dir:
                     try:
                         # This can happen if an archive was created with a command line like
                         # $ borg create ... dir1/file dir1
                         # In this case the code below will have created a default_dir inode for dir1 already.
-                        inode = self._find_inode(item.path, prefix)
+                        inode = self._find_inode(path, prefix)
                     except KeyError:
                         pass
                     else:
                         self.items[inode] = item
                         continue
-                segments = prefix + os.fsencode(os.path.normpath(item.path)).split(b'/')
-                del item.path
+                segments = prefix + path.split(b'/')
                 parent = 1
                 for segment in segments[:-1]:
                     parent = self.process_inner(segment, parent)
                 self.process_leaf(segments[-1], item, parent, prefix, is_dir)
 
     def process_leaf(self, name, item, parent, prefix, is_dir):
-        def version_name(name, item):
+        def file_version(item):
             if 'chunks' in item:
                 ident = 0
                 for chunkid, _, _ in item.chunks:
                     ident = adler32(chunkid, ident)
-                name = name + safe_encode('.%08x' % ident)
-            return name
+                return ident
+
+        def make_versioned_name(name, version, add_dir=False):
+            if add_dir:
+                # add intermediate directory with same name as filename
+                path_fname = name.rsplit(b'/', 1)
+                name += b'/' + path_fname[-1]
+            return name + os.fsencode('.%08x' % version)
 
         if self.versions and not is_dir:
             parent = self.process_inner(name, parent)
-            name = version_name(name, item)
-
+            version = file_version(item)
+            if version is not None:
+                # regular file, with contents - maybe a hardlink master
+                name = make_versioned_name(name, version)
+                path = os.fsencode(os.path.normpath(item.path))
+                self.file_versions[path] = version
+
+        del item.path  # safe some space
         if 'source' in item and stat.S_ISREG(item.mode):
-            inode = self._find_inode(item.source, prefix)
+            # a hardlink, no contents, <source> is the hardlink master
+            source = os.fsencode(os.path.normpath(item.source))
+            if self.versions:
+                # adjust source name with version
+                version = self.file_versions[source]
+                source = make_versioned_name(source, version, add_dir=True)
+                name = make_versioned_name(name, version)
+            inode = self._find_inode(source, prefix)
             item = self.cache.get(inode)
             item.nlink = item.get('nlink', 1) + 1
             self.items[inode] = item
@@ -215,7 +238,7 @@ def get_item(self, inode):
             return self.cache.get(inode)
 
     def _find_inode(self, path, prefix=[]):
-        segments = prefix + os.fsencode(os.path.normpath(path)).split(b'/')
+        segments = prefix + path.split(b'/')
         inode = 1
         for segment in segments:
             inode = self.contents[inode][segment]
diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py
--- a/src/borg/testsuite/archiver.py
+++ b/src/borg/testsuite/archiver.py
@@ -1453,6 +1453,9 @@ def has_noatime(some_file):
     def test_fuse_versions_view(self):
         self.cmd('init', self.repository_location)
         self.create_regular_file('test', contents=b'first')
+        if are_hardlinks_supported():
+            self.create_regular_file('hardlink1', contents=b'')
+            os.link('input/hardlink1', 'input/hardlink2')
         self.cmd('create', self.repository_location + '::archive1', 'input')
         self.create_regular_file('test', contents=b'second')
         self.cmd('create', self.repository_location + '::archive2', 'input')
@@ -1463,6 +1466,10 @@ def test_fuse_versions_view(self):
             files = os.listdir(path)
             assert all(f.startswith('test.') for f in files)  # ... with files test.xxxxxxxx in there
             assert {b'first', b'second'} == {open(os.path.join(path, f), 'rb').read() for f in files}
+            if are_hardlinks_supported():
+                st1 = os.stat(os.path.join(mountpoint, 'input', 'hardlink1', 'hardlink1.00000000'))
+                st2 = os.stat(os.path.join(mountpoint, 'input', 'hardlink2', 'hardlink2.00000000'))
+                assert st1.st_ino == st2.st_ino
 
     @unittest.skipUnless(has_llfuse, 'llfuse not installed')
     def test_fuse_allow_damaged_files(self):


Test patch: