Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions eoldoctor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions eoldoctor/fixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions eoldoctor/walker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 12 additions & 0 deletions tests/test_fixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions tests/test_walker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading