Repository: borgbackup/borg

Issue:
can't "create" where upper directory is reachable but non-readable
<!--
Thank you for reporting an issue.

*IMPORTANT* -  *before* creating a new issue please look around:
 - Borgbackup documentation: http://borgbackup.readthedocs.io/en/stable/index.html
 - FAQ: https://borgbackup.readthedocs.io/en/stable/faq.html
 and
 - open issues in GitHub tracker: https://github.com/borgbackup/borg/issues
  
If you cannot find a similar problem, then create a new issue.

Please fill in as much of the template as possible.
-->

## Have you checked borgbackup docs, FAQ, and open GitHub issues?

Yes

## Is this a BUG / ISSUE report or a QUESTION?

BUG

## System information. For client/server mode post info for both machines.



#### Your borg version (borg -V).

borg 1.2.0

#### Operating system (distribution) and version.

ubuntu server 22.04

#### Hardware / network configuration, and filesystems used.

#### How much data is handled by borg?

#### Full borg commandline that lead to the problem (leave away excludes and passwords)


## Describe the problem you're observing.

where parent of backing up tree is not readable (but accessible, like 0711), borg can't start backup with the following exception

#### Can you reproduce the problem? If so, describe how. If not, describe troubleshooting steps you took before opening the issue.

```
mkdir -m 711 /fs/parent
mkdir -m 755 /fs/parent/tree
touch /fs/parent/tree/backupfile.txt
```

(from other user) 

```
borg create /path/to/repo::tag /fs/parent/tree
```

#### Include any warning/errors/backtraces from the system logs

```
marck@box:~$ borg --debug create --progress --stats borg/repo::Initial /fs/users/marck/backups/games/
...
Local Exception
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/borg/helpers/fs.py", line 290, in os_open
    fd = os.open(fname, _flags_noatime, dir_fd=parent_fd)
PermissionError: [Errno 13] Permission denied: '/fs/users/marck/backups'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/borg/archive.py", line 264, in OsOpen
    fd = os_open(path=path, parent_fd=parent_fd, name=name, flags=flags, noatime=noatime)
```



Discussion:
Note: can also be reproduced with current 1.2-maint code.

Looking at the traceback, guess that is because borg (since 1.2) tries to always work with FDs rather than file names to avoid race conditions.

That is not only done for regular files, but also for directories, including the directory where the recursion starts.

For that it opens the parent directory and gets its FD and then opens the wanted directory giving its basename and the parent directory FD.

See `borg.archiver:do_create`, in `create_inner`, where it calls `OsOpen(path=parent_dir, ...)`.
I thought about this and although the setup with unreadable parent is a bit unusual, I guess we have to consider this a bug.

Resolving patch:
diff --git a/src/borg/archiver.py b/src/borg/archiver.py
--- a/src/borg/archiver.py
+++ b/src/borg/archiver.py
@@ -590,31 +590,20 @@ def create_inner(archive, cache, fso):
                         self.print_file_status(status, path)
                         continue
                     path = os.path.normpath(path)
-                    parent_dir = os.path.dirname(path) or '.'
-                    name = os.path.basename(path)
                     try:
-                        # note: for path == '/':  name == '' and parent_dir == '/'.
-                        # the empty name will trigger a fall-back to path-based processing in os_stat and os_open.
-                        with OsOpen(path=parent_dir, flags=flags_root, noatime=True, op='open_root') as parent_fd:
-                            try:
-                                st = os_stat(path=path, parent_fd=parent_fd, name=name, follow_symlinks=False)
-                            except OSError as e:
-                                self.print_warning('%s: %s', path, e)
-                                continue
-                            if args.one_file_system:
-                                restrict_dev = st.st_dev
-                            else:
-                                restrict_dev = None
-                            self._rec_walk(path=path, parent_fd=parent_fd, name=name,
-                                           fso=fso, cache=cache, matcher=matcher,
-                                           exclude_caches=args.exclude_caches, exclude_if_present=args.exclude_if_present,
-                                           keep_exclude_tags=args.keep_exclude_tags, skip_inodes=skip_inodes,
-                                           restrict_dev=restrict_dev, read_special=args.read_special, dry_run=dry_run)
-                            # if we get back here, we've finished recursing into <path>,
-                            # we do not ever want to get back in there (even if path is given twice as recursion root)
-                            skip_inodes.add((st.st_ino, st.st_dev))
+                        with backup_io('stat'):
+                            st = os_stat(path=path, parent_fd=None, name=None, follow_symlinks=False)
+                        restrict_dev = st.st_dev if args.one_file_system else None
+                        self._rec_walk(path=path, parent_fd=None, name=None,
+                                       fso=fso, cache=cache, matcher=matcher,
+                                       exclude_caches=args.exclude_caches, exclude_if_present=args.exclude_if_present,
+                                       keep_exclude_tags=args.keep_exclude_tags, skip_inodes=skip_inodes,
+                                       restrict_dev=restrict_dev, read_special=args.read_special, dry_run=dry_run)
+                        # if we get back here, we've finished recursing into <path>,
+                        # we do not ever want to get back in there (even if path is given twice as recursion root)
+                        skip_inodes.add((st.st_ino, st.st_dev))
                     except (BackupOSError, BackupError) as e:
-                        # this comes from OsOpen, self._rec_walk has own exception handler
+                        # this comes from os.stat, self._rec_walk has own exception handler
                         self.print_warning('%s: %s', path, e)
                         continue
             if not dry_run:
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
@@ -487,6 +487,16 @@ def test_init_parent_dirs(self):
         self.cmd('init', '--encryption=none', '--make-parent-dirs', repository_location)
         assert os.path.exists(parent_path)
 
+    def test_create_unreadable_parent(self):
+        parent_dir = os.path.join(self.input_path, 'parent')
+        root_dir = os.path.join(self.input_path, 'parent', 'root')
+        os.mkdir(parent_dir)
+        os.mkdir(root_dir)
+        os.chmod(parent_dir, 0o111)  # --x--x--x == parent dir traversable, but not readable
+        self.cmd('init', '--encryption=none', self.repository_location)
+        # issue #7746: we *can* read root_dir and we *can* traverse parent_dir, so this should work:
+        self.cmd('create', self.repository_location + '::test', root_dir)
+
     def test_unix_socket(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         try:


Test patch: