Repository: borgbackup/borg

Issue:
Borg extract crashes with --strip-components option
<!--
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.1.17`

#### Operating system (distribution) and version.

OS: Ubuntu 20.04.3 LTS

#### Hardware / network configuration, and filesystems used.

FileSystem: ext4

#### How much data is handled by borg?

<10 GB

#### Full borg commandline that lead to the problem (leave away excludes and passwords)

```
borg extract --list --strip-components 2 /tmp/TMP_TESTS/borg_bug/borg-extract-bug/repository::archive_1::archive_1
```

## Describe the problem you're observing.

Borg extract command prematurely exits with the following trace. Also, it is to be noted that extract command works as expected when --strip-components argument is not provided.
```

Local Exception
Traceback (most recent call last):
  File "borg/archiver.py", line 4703, in main
  File "borg/archiver.py", line 4635, in run
  File "borg/archiver.py", line 177, in wrapper
  File "borg/archiver.py", line 192, in wrapper
  File "borg/archiver.py", line 794, in do_extract
  File "borg/archive.py", line 474, in iter_items
  File "borg/archive.py", line 237, in unpack_many
KeyError: 'tmp/TMP_TESTS/borg_bug/borg-extract-bug/data_dir/fifo_file'

Platform: Linux <user-name> 5.4.0-89-generic #100-Ubuntu SMP Fri Sep 24 14:50:10 UTC 2021 x86_64
Linux: debian bullseye/sid 
Borg: 1.1.17  Python: CPython 3.7.11 msgpack: 0.5.6.+borg1
PID: 19654  CWD: /tmp/TMP_TESTS/borg_bug/borg-extract-bug/extract_dir
sys.argv: ['/tmp/TMP_TESTS/borg_bug/borg-extract-bug/borg', 'extract', '--list', '--strip-components', '2', '/tmp/TMP_TESTS/borg_bug/borg-extract-bug/repository::archive_1']
SSH_ORIGINAL_COMMAND: None
```

#### Can you reproduce the problem? If so, describe how. If not, describe troubleshooting steps you took before opening the issue.

Yes. 

How to reproduce this issue?
1. Create a fifo file and create a hardlink to it. 
2. Execute borg create with absolute path to the data directory.
3. Extract the archive created in step 2 with appropriate  --strip-components argument.

I'm able to reproduce the same behavior using the following script.

```
#! /bin/bash

# Make directories
mkdir borg-extract-bug
pushd borg-extract-bug
BORG_DIR=$(pwd)

mkdir data_dir repository extract_dir

# Download borg binary
curl -L https://github.com/borgbackup/borg/releases/download/1.1.17/borg-linux64 -o borg
chmod +x borg
BORG_EXEC="${BORG_DIR}/borg"

# Add data
mkfifo $(pwd)/data_dir/fifo_file
ln $(pwd)/data_dir/fifo_file $(pwd)/data_dir/hfifo_file

# Borg repository
BORG_REPOSITORY="$(pwd)/repository"

$BORG_EXEC init -e=none ${BORG_REPOSITORY}
$BORG_EXEC create ${BORG_REPOSITORY}::archive_1 $(pwd)/data_dir

# Borg extract 
pushd extract_dir
$BORG_EXEC extract --list --strip-components 2 ${BORG_REPOSITORY}::archive_1
popd

popd
```



#### Include any warning/errors/backtraces from the system logs

N/A
<!--

If this complaint relates to borg performance, please include CRUD benchmark
results and any steps you took to troubleshoot.
How to run benchmark: http://borgbackup.readthedocs.io/en/stable/usage/benchmark.html

*IMPORTANT* - Please mark logs and text output from terminal commands 
or else Github will not display them correctly. 
An example is provided below.

Example:
```
this is an example how log text should be marked (wrap it with ```)
```
-->



Discussion:
Thanks for reporting this bug, I could reproduce it using your script.

Resolving patch:
diff --git a/src/borg/archive.py b/src/borg/archive.py
--- a/src/borg/archive.py
+++ b/src/borg/archive.py
@@ -288,19 +288,21 @@ def _preload(chunks):
                     # corresponding hardlink slave is selected (== is extracted).
                     # due to a side effect of the filter() call, we now have hardlink_masters dict populated.
                     for item in items:
-                        if 'chunks' in item:  # regular file, maybe a hardlink master
-                            _preload(item.chunks)
-                            # if this is a hardlink master, remember that we already preloaded it:
-                            if 'source' not in item and hardlinkable(item.mode) and item.get('hardlink_master', True):
-                                masters_preloaded.add(item.path)
-                        elif 'source' in item and hardlinkable(item.mode):  # hardlink slave
-                            source = item.source
-                            if source not in masters_preloaded:
-                                # we only need to preload *once* (for the 1st selected slave)
-                                chunks, _ = hardlink_masters[source]
-                                if chunks is not None:
-                                    _preload(chunks)
-                                masters_preloaded.add(source)
+                        if hardlinkable(item.mode):
+                            source = item.get('source')
+                            if source is None:  # maybe a hardlink master
+                                if 'chunks' in item:
+                                    _preload(item.chunks)
+                                # if this is a hl master, remember that we already preloaded all chunks of it (if any):
+                                if item.get('hardlink_master', True):
+                                    masters_preloaded.add(item.path)
+                            else:  # hardlink slave
+                                if source not in masters_preloaded:
+                                    # we only need to preload *once* (for the 1st selected slave)
+                                    chunks, _ = hardlink_masters[source]
+                                    if chunks is not None:
+                                        _preload(chunks)
+                                    masters_preloaded.add(source)
                 else:
                     # easy: we do not have a filter, thus all items are selected, thus we need to preload all chunks.
                     for item in items:


Test patch: