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 ( 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."""