Handle special filesystem entries explicitly (#11)

* Handle special filesystem entries explicitly

- Added fs.entry_kind(): classifies via lstat() mode bits alone
  ("file", "dir", "symlink", "fifo", "socket", "block_device",
  "char_device", "unknown") - never opens the entry, so classifying a
  FIFO can't block even without a writer.
- rsync() now classifies every source entry before touching it and
  yields a new Actions.EXCLUDE for anything outside file/dir/symlink,
  instead of falling through to copy_direntry()'s file branch, which
  would either block forever opening a writerless FIFO for read, or
  raise an OSError that initiate_backup() treats as fatal (aborting the
  whole backup for one socket).
- initiate_backup() records exclusions in the completion manifest
  (keyed by snapshot-relative path, valued with the excluded kind), and
  no longer nest_hardlinks or counts a recurring exclusion as a change
  worth keeping a new snapshot for.
- Chose the "recorded exclusion" policy the plan allows, rather than
  fail-fast: a FIFO or socket living in a backed-up tree is not itself
  a backup failure, and previously turned into one.

Note: rsync_ext() (external rsync) is unaffected by this change and
keeps whatever its own --archive flags do with special files; the P1
item on Python/external implementation parity covers reconciling that.
The pre-existing test_src_is_socket failure (AF_UNIX path length on
macOS tmp_path) is untouched here - it's separately tracked under P1
test fixes - but this PR adds working socket/FIFO/char-device coverage
via a short-path tempdir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address Copilot review: fix stale exclude, exclusion-only diffs, test portability

- fs.rsync(): remove the existing dst_entry before yielding EXCLUDE for
  a src entry with an unsupported kind, so an entry inherited via
  hardlink from a prior snapshot doesn't linger in the new one.
- backup.initiate_backup(): compare this run's excluded set against
  the previous snapshot's manifest; a change in exclusions alone now
  counts as backup_changed, so the updated manifest gets written.
- tests: skip mkfifo/AF_UNIX-dependent tests on platforms without
  support, and use TemporaryDirectory/context managers instead of
  manual mkdtemp/unlink for more robust cleanup.

* Remove unused shutil import from test_rsync.py

Left over after switching the socket/fifo tests to
tempfile.TemporaryDirectory(), per Copilot's suppressed comment.

* Fix rm_direntry() no-op on special dst entries

rm_direntry() only handled file/symlink/dir - a FIFO, socket, or
device node at dst (e.g. inherited from an external-rsync snapshot)
silently survived removal, since is_file/is_dir/is_symlink all
return False for it. Any non-directory entry is unlinkable the same
way, so invert the branching: recurse for directories, unlink
everything else.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-04 03:25:37 +00:00
1 parent b4db7c806b
commit a29517e2fc
5 files changed
+233 -15

No files matched your search

+100
View File
@@ -709,3 +709,103 @@ class TestRsyncHardlinkImmutability:
assert os.lstat(str(external_target)).st_mode == external_mode_before
assert (external_target / "canary").read_text() == "do-not-touch"
class TestEntryKind:
"""Tests for fs.entry_kind() classification."""
def test_regular_file(self, tmp_path):
path = os.path.join(str(tmp_path), "f")
with open(path, "w") as f:
f.write("x")
with os.scandir(str(tmp_path)) as it:
entry = next(iter(it))
assert fs.entry_kind(entry) == "file"
def test_directory(self, tmp_path):
path = os.path.join(str(tmp_path), "d")
os.mkdir(path)
with os.scandir(str(tmp_path)) as it:
entry = next(iter(it))
assert fs.entry_kind(entry) == "dir"
def test_symlink(self, tmp_path):
target = os.path.join(str(tmp_path), "target")
link = os.path.join(str(tmp_path), "link")
with open(target, "w") as f:
f.write("x")
os.symlink(target, link)
with os.scandir(str(tmp_path)) as it:
entries = {e.name: e for e in it}
assert fs.entry_kind(entries["link"]) == "symlink"
@pytest.mark.skipif(not hasattr(os, "mkfifo"),
reason="os.mkfifo not available on this platform")
def test_fifo(self, tmp_path):
path = os.path.join(str(tmp_path), "fifo")
os.mkfifo(path)
with os.scandir(str(tmp_path)) as it:
entry = next(iter(it))
assert fs.entry_kind(entry) == "fifo"
@pytest.mark.skipif(not os.path.exists("/dev/null"),
reason="no /dev/null on this platform")
def test_char_device(self):
entry = fs.PseudoDirEntry("/dev/null")
assert fs.entry_kind(entry) == "char_device"
@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"),
reason="AF_UNIX not available on this platform")
def test_socket(self):
import tempfile
# AF_UNIX paths are capped at ~104-108 bytes; pytest's tmp_path
# nests too deep on macOS, so use a short-path tempdir directly.
with tempfile.TemporaryDirectory() as d:
sock_path = os.path.join(d, "s")
with socket.socket(socket.AF_UNIX) as sock:
sock.bind(sock_path)
with os.scandir(d) as it:
entry = next(iter(it))
assert fs.entry_kind(entry) == "socket"
class TestRsyncExcludesUnsupportedEntries:
"""
Regression tests: rsync() must classify FIFOs/sockets/devices via
lstat() alone (never opening them) and exclude them with a visible
EXCLUDE action, instead of either blocking forever (a FIFO with no
writer) or treating them as a fatal Actions.ERROR that aborts the
whole backup.
"""
@pytest.mark.skipif(not hasattr(os, "mkfifo"),
reason="os.mkfifo not available on this platform")
def test_fifo_without_writer_does_not_block(self, common_fs_dirs):
src_dir, dst_dir = common_fs_dirs
fifo_path = os.path.join(str(src_dir), "a_fifo")
os.mkfifo(fifo_path)
# No writer is ever opened on the other end. If rsync() opened
# this for reading, this test would hang instead of failing.
results = list(fs.rsync(str(src_dir), str(dst_dir)))
assert ("a_fifo", fs.Actions.EXCLUDE, "fifo") in results
assert not os.path.lexists(os.path.join(str(dst_dir), "a_fifo"))
@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"),
reason="AF_UNIX not available on this platform")
def test_socket_is_excluded_not_fatal(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
src_dir = os.path.join(d, "src")
dst_dir = os.path.join(d, "dst")
os.mkdir(src_dir)
os.mkdir(dst_dir)
sock_path = os.path.join(src_dir, "a_socket")
with socket.socket(socket.AF_UNIX) as sock:
sock.bind(sock_path)
results = list(fs.rsync(src_dir, dst_dir))
assert ("a_socket", fs.Actions.EXCLUDE, "socket") in results
assert fs.Actions.ERROR not in [r[1] for r in results]
assert not os.path.lexists(os.path.join(dst_dir, "a_socket"))