Repository: borgbackup/borg

Issue:
borg check causes corruption in hardlinked config file
## 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.

Tested on Ubuntu 20.04 and 21.10.

#### Hardware / network configuration, and filesystems used.

ext4

#### How much data is handled by borg?

Doesn't matter.

#### Full borg commandline that lead to the problem (leave away excludes and passwords)

```
mkdir /tmp/borgtest
cd /tmp/borgtest
borg init -e none test.repo
cp -al test.repo copy.repo
borg check -v test.repo
cat copy.repo/config  [binary data]
```

## Describe the problem you're observing.

After making a hardlink copy of a repo, running check on the original causes corruption of the config file in the copy.  It happens with archives in the repo too, but I omitted this to make the script as short as possible.

#### Can you reproduce the problem? If so, describe how. If not, describe troubleshooting steps you took before opening the issue.

Reproducible using the steps above.

#### Include any warning/errors/backtraces from the system logs

None.



Discussion:
Note: I fixed a similar issue with the segment files a while ago.
I can reproduce the issue with 1.2.1.
Hmm, that is a bug and a feature.

https://github.com/borgbackup/borg/blob/1.2.1/src/borg/repository.py#L299

It's always secure erasing the old contents of the config file when it writes a new config - and that secure erase intentionally overwrites the old contents in the old inode.

Guess we made that to completely get rid of an old repokey (the repokey is encrypted, so it is only a problem if the passphrase was disclosed). If one changes the passphrase (before the attacker can read the old repokey), one stays safe **if** one really gets rid of the old repokey.

Maybe `save_config` should get a `secure_erase=False` argument.

`Repository.save_key` could then call it giving `secure_erase=True`.

That would avoid this collateral damage in all cases except when saving a repokey (e.g. when changing the passphrase).

Update: OTOH, that would make the repokey "live on" in a lot of freed (but not erased) storage medium blocks.
Oh, I see.  One option would be to not do the secure erase iff the config file has a link count > 1.
Yeah, I just had the same idea. :-)

Resolving patch:
diff --git a/src/borg/helpers/fs.py b/src/borg/helpers/fs.py
--- a/src/borg/helpers/fs.py
+++ b/src/borg/helpers/fs.py
@@ -189,13 +189,23 @@ def scandir_inorder(*, path, fd=None):
     return sorted(os.scandir(arg), key=scandir_keyfunc)
 
 
-def secure_erase(path):
-    """Attempt to securely erase a file by writing random data over it before deleting it."""
+def secure_erase(path, *, avoid_collateral_damage):
+    """Attempt to securely erase a file by writing random data over it before deleting it.
+
+    If avoid_collateral_damage is True, we only secure erase if the total link count is 1,
+    otherwise we just do a normal "delete" (unlink) without first overwriting it with random.
+    This avoids other hardlinks pointing to same inode as <path> getting damaged, but might be less secure.
+    A typical scenario where this is useful are quick "hardlink copies" of bigger directories.
+
+    If avoid_collateral_damage is False, we always secure erase.
+    If there are hardlinks pointing to the same inode as <path>, they will contain random garbage afterwards.
+    """
     with open(path, 'r+b') as fd:
-        length = os.stat(fd.fileno()).st_size
-        fd.write(os.urandom(length))
-        fd.flush()
-        os.fsync(fd.fileno())
+        st = os.stat(fd.fileno())
+        if not (st.st_nlink > 1 and avoid_collateral_damage):
+            fd.write(os.urandom(st.st_size))
+            fd.flush()
+            os.fsync(fd.fileno())
     os.unlink(path)
 
 
diff --git a/src/borg/repository.py b/src/borg/repository.py
--- a/src/borg/repository.py
+++ b/src/borg/repository.py
@@ -306,7 +306,7 @@ def save_config(self, path, config):
 
         if os.path.isfile(old_config_path):
             logger.warning("Old config file not securely erased on previous config update")
-            secure_erase(old_config_path)
+            secure_erase(old_config_path, avoid_collateral_damage=True)
 
         if os.path.isfile(config_path):
             link_error_msg = ("Failed to securely erase old repository config file (hardlinks not supported). "
@@ -333,7 +333,7 @@ def save_config(self, path, config):
                            "read-only repositories." % (e.strerror, e.filename))
 
         if os.path.isfile(old_config_path):
-            secure_erase(old_config_path)
+            secure_erase(old_config_path, avoid_collateral_damage=True)
 
     def save_key(self, keydata):
         assert self.config


Test patch: