From 4d99a8d80a9a95ad9fcb04430cda10f1fb7f0792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 18:36:23 +0800 Subject: [PATCH 1/2] fix: refuse rewriting through symbolic links (rebased onto main, part 1) --- eoldoctor/fixer.py | 3 +++ eoldoctor/walker.py | 5 +++-- tests/test_fixer.py | 12 ++++++++++++ tests/test_walker.py | 7 +++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/eoldoctor/fixer.py b/eoldoctor/fixer.py index 3e5617b..9c27024 100644 --- a/eoldoctor/fixer.py +++ b/eoldoctor/fixer.py @@ -138,6 +138,9 @@ def apply_action( ) -> bool: """Apply one action atomically. Returns True when the file changed.""" path = action.path + if os.path.islink(path): + action.error = "refusing to rewrite a symbolic link" + return False try: with open(path, "rb") as handle: original = handle.read() diff --git a/eoldoctor/walker.py b/eoldoctor/walker.py index c9b0d07..56ba5ec 100644 --- a/eoldoctor/walker.py +++ b/eoldoctor/walker.py @@ -102,8 +102,9 @@ def push(rel: str, filtered: bool = True) -> None: out.append(rel) for arg in args: - if os.path.islink(arg) and not os.path.isdir(arg): - continue + # Named file symlinks are analyzed (open follows the target). + # Directory walks still skip file symlinks so a stray link cannot + # pull an outside path into a repo-wide scan/fix. if os.path.isfile(arg): push(_safe_relpath(arg), filtered=False) continue diff --git a/tests/test_fixer.py b/tests/test_fixer.py index dbae12b..9b86d01 100644 --- a/tests/test_fixer.py +++ b/tests/test_fixer.py @@ -154,6 +154,18 @@ def test_apply_missing_file_sets_error(self): self.assertFalse(apply_action(action)) self.assertIsNotNone(action.error) + @unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows") + def test_apply_refuses_symlink(self): + target = self._write(b"a\r\nb\r\n", name="real.txt") + link = os.path.join(os.path.dirname(target), "link.txt") + os.symlink(target, link) + action = FixAction(path=link, target="lf", ops=["crlf->lf(2)"]) + self.assertFalse(apply_action(action)) + self.assertIn("symbolic link", action.error) + with open(target, "rb") as handle: + self.assertEqual(handle.read(), b"a\r\nb\r\n") + self.assertTrue(os.path.islink(link)) + @unittest.skipIf(os.name == "nt", "POSIX permissions only") def test_apply_preserves_mode(self): path = self._write(b"#!/bin/sh\r\necho\r\n", name="run.sh") diff --git a/tests/test_walker.py b/tests/test_walker.py index 9ac0815..34803fd 100644 --- a/tests/test_walker.py +++ b/tests/test_walker.py @@ -98,6 +98,13 @@ def test_symlinks_skipped(self): found = collect_files(["."], use_git=False) self.assertEqual(found, ["real.txt"]) + @unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows") + def test_explicit_file_symlink_is_analyzed(self): + self.write("real.txt") + os.symlink("real.txt", os.path.join(self.root, "link.txt")) + found = collect_files(["link.txt"], use_git=False) + self.assertEqual(found, ["link.txt"]) + @unittest.skipUnless(GIT, "git not installed") class GitModeTest(ChdirTestCase): From 7309e8b526ab05661a9346b3a9b0f0c985365520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=98=E9=9B=85=E3=81=AE=E5=92=B8=E9=B1=BC?= <3129538298@qq.com> Date: Wed, 26 Aug 2026 18:37:11 +0800 Subject: [PATCH 2/2] fix: keep symlink write guards together with OSError handling --- eoldoctor/cli.py | 25 ++++++++++++++----------- tests/test_cli.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/eoldoctor/cli.py b/eoldoctor/cli.py index c08b496..5e8e013 100644 --- a/eoldoctor/cli.py +++ b/eoldoctor/cli.py @@ -385,17 +385,20 @@ def cmd_attributes(args) -> int: written = False write_error = None if content and args.write: - # Match the EOL style already used by the file we are appending to, - # so we never create a mixed-EOL .gitattributes ourselves. - eol = "\r\n" if "\r\n" in existing else "\n" - try: - with open(path, "a", encoding="utf-8", newline=eol) as handle: - if existing and not existing.endswith("\n"): - handle.write("\n") - handle.write(content) - written = True - except OSError as exc: - write_error = exc.strerror or str(exc) + if os.path.islink(path): + write_error = "refusing to write through a symbolic link" + else: + # Match the EOL style already used by the file we are appending to, + # so we never create a mixed-EOL .gitattributes ourselves. + eol = "\r\n" if "\r\n" in existing else "\n" + try: + with open(path, "a", encoding="utf-8", newline=eol) as handle: + if existing and not existing.endswith("\n"): + handle.write("\n") + handle.write(content) + written = True + except OSError as exc: + write_error = exc.strerror or str(exc) if args.json: print( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b25d69..311beca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -285,6 +285,19 @@ def test_write_keeps_lf_style(self): self.assertNotIn(b"\r\n", data) self.assertIn(b"* text=auto\n", data) + @unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows") + def test_write_refuses_gitattributes_symlink(self): + outside = self.write("outside.attrs", b"# outside\n") + os.symlink(outside, os.path.join(self.root, ".gitattributes")) + self.write("run.sh", b"echo\n") + code, out, _ = self.run_cli("attributes", "--no-git", "--write", "--json") + self.assertEqual(code, 1) + payload = json.loads(out) + self.assertFalse(payload["written"]) + self.assertIn("symbolic link", payload["error"]) + self.assertEqual(self.read("outside.attrs"), b"# outside\n") + self.assertTrue(os.path.islink(os.path.join(self.root, ".gitattributes"))) + @unittest.skipIf(os.name == "nt", "POSIX permissions only") def test_write_permission_error_exits_1(self): self.write("run.sh", b"echo\n")