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

+32
View File
@@ -1081,3 +1081,35 @@ def test_external_rsync_snapshot_still_gets_checksums(integration_dirs):
manifest = bk._read_manifest(bk._get_backup_marker(snapshot).path)
assert manifest["checksums"]
assert bk.verify_snapshot(snapshot) == []
@pytest.mark.skipif(not hasattr(os, "mkfifo"),
reason="os.mkfifo not available on this platform")
def test_fifo_excluded_and_recorded_without_blocking(integration_dirs):
"""
A FIFO in the source must not block the backup (no writer is ever
attached), must not abort it, and must be recorded in the manifest
as an exclusion rather than silently disappearing.
"""
backups_dir, source_dir = integration_dirs
(source_dir / "file1.txt").write_text("content1")
os.mkfifo(str(source_dir / "a_fifo"))
snapshot = bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
)
assert snapshot is not None
source_name = os.path.basename(str(source_dir))
manifest = bk._read_manifest(bk._get_backup_marker(snapshot).path)
assert manifest["excluded"] == {
os.path.join(source_name, "a_fifo"): "fifo"
}
assert not os.path.lexists(
os.path.join(snapshot.path, source_name, "a_fifo")
)
assert os.path.exists(
os.path.join(snapshot.path, source_name, "file1.txt")
)
+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"))