Repository: anishathalye/dotbot

Issue:
Accidental file deletion when linking
While configuring my dotbot config, I ran into an issue that caught me off guard and led to the accidental deletion of _source_ files.

```
# Directory structure
dotbot.yml
t_source/
------> /config.json
```

````yaml
# Initial dotbot.yml
- defaults:
    link:
      relink: true
      create: true
      force: true

- link:
    t_target: t_source
````

After running `dotbot -c dotbot.yml`, I get the following

```
$ dotbot -c dotbot.yml
Creating link t_target -> /home/user/test/t_source
All links have been set up

==> All tasks executed successfully

$ ls -l
-rw-rw-r-- 1 user user  110 Mar 21 22:48 dotbot.yml
drwxrwxr-x 2 user user 4096 Mar 21 22:47 t_source/
lrwxrwxrwx 1 user user   27 Mar 21 22:50 t_target -> /home/user/test/t_source/

$ ls t_target
config.json
```

Now, I wanted to change my config to instead link the file directly (`config.json`) instead of the directory (`t_source`).

```diff
    - link:
-       t_target: t_source
+       t_target/config.json: t_source/config.json
````

Here is where the issue lies. Remember:

1. `t_target` is a symlink pointing to `t_source`
2. `t_target/config.json` **exists** since we previously linked it.

So when dotbot goes to link this file, it sees that `t_target/config.json` exists and **deletes** it to make room for the new link. Since this points to the real original file, `t_source/config.json` is deleted!

```
$ ls t_source/config.json
t_source/config.json

$ dotbot -c dotbot.yml
Removing t_target/config.json
Nonexistent source for t_target/config.json : /home/user/test/t_source/config.json
Some links were not successfully set up

==> Some tasks were not executed successfully

$ ls t_source/config.json
ls: cannot access 't_source/config.json': No such file or directory
```

This is fundamentally user error, but I think it's worth putting some checks around since not only did it delete my source file, but it wasn't clear why it did it until I took some time to investigate.

Discussion:

Solution patch:
diff --git a/README.md b/README.md
index f8108a6..aa4acff 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/workflows/CI/badge.svg)](https://github.com/anishathalye/dotbot/actions?query=workflow%3ACI) [![Coverage](https://codecov.io/gh/anishathalye/dotbot/branch/master/graph/badge.svg)](https://app.codecov.io/gh/anishathalye/dotbot) [![PyPI](https://img.shields.io/pypi/v/dotbot.svg)](https://pypi.org/pypi/dotbot/) [![Python 3.7+](https://img.shields.io/badge/python-3.7%2B-blue)](https://pypi.org/pypi/dotbot/)
+# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml/badge.svg)](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/anishathalye/dotbot/branch/master/graph/badge.svg)](https://app.codecov.io/gh/anishathalye/dotbot) [![PyPI](https://img.shields.io/pypi/v/dotbot.svg)](https://pypi.org/pypi/dotbot/) [![PyPI - Python version](https://img.shields.io/pypi/pyversions/dotbot.svg)](https://pypi.org/pypi/dotbot/)
 
 Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles
 && ./install`, even on a freshly installed system!
diff --git a/src/dotbot/plugins/link.py b/src/dotbot/plugins/link.py
index e02335e..00c3435 100644
--- a/src/dotbot/plugins/link.py
+++ b/src/dotbot/plugins/link.py
@@ -197,6 +197,12 @@ class Link(Plugin):
         success = True
         source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
         fullpath = os.path.abspath(os.path.expanduser(path))
+        if self._exists(path) and not self._is_link(path) and os.path.realpath(fullpath) == source:
+            # Special case: The path is not a symlink but resolves to the source anyway.
+            # Deleting the path would actually delete the source.
+            # This may happen if a parent directory is a symlink.
+            self._log.warning(f"{path} appears to be the same file as {source}.")
+            return False
         if relative:
             source = self._relative_path(source, fullpath)
         if (self._is_link(path) and self._link_destination(path) != source) or (


Test patch:
diff --git a/tests/test_link.py b/tests/test_link.py
index b3e8966..389aa72 100644
--- a/tests/test_link.py
+++ b/tests/test_link.py
@@ -1,4 +1,5 @@
 import os
+import pathlib
 import sys
 from typing import Callable, Optional
 
@@ -962,6 +963,50 @@ def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dot
     assert mtime == new_mtime
 
 
+def test_source_is_not_overwritten_by_symlink_trickery(
+    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
+) -> None:
+    dotfiles_path = pathlib.Path(dotfiles.directory)
+    home_path = pathlib.Path(home)
+
+    # Setup:
+    #   *   A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory.
+    #   *   Dotbot is configured to force-recreate a symlink between two files
+    #       when, in reality, it's actually the same file when resolved.
+    ssh_config = (dotfiles_path / "ssh/config").absolute()
+    os.mkdir(str(ssh_config.parent))
+    ssh_config.write_text("preserve me!")
+    os.symlink(str(ssh_config.parent), str(home_path / ".ssh"))
+    dotfiles.write_config(
+        [
+            {
+                "defaults": {
+                    "link": {
+                        "relink": True,
+                        "create": True,
+                        "force": True,
+                    },
+                }
+            },
+            {
+                "link": {
+                    # When symlinks are resolved, these are actually the same file.
+                    "~/.ssh/config": "ssh/config",
+                },
+            },
+        ]
+    )
+
+    # Execute dotbot.
+    with pytest.raises(SystemExit):
+        run_dotbot()
+
+    stdout, _ = capsys.readouterr()
+    assert "appears to be the same file" in stdout
+    # Verify that the file was not overwritten.
+    assert ssh_config.read_text() == "preserve me!"
+
+
 def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
     """Verify that link doesn't overwrite non-dotfiles links by default."""