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

+65 -4
View File
@@ -7,6 +7,7 @@ import glob
import hashlib
import logging
import os
import stat
import subprocess
import sys
import uuid
@@ -28,6 +29,40 @@ class Actions(enum.Enum):
UPDATE_OWNER = enum.auto()
CREATE = enum.auto()
ERROR = enum.auto()
EXCLUDE = enum.auto()
# Entry kinds copy_direntry() knows how to back up. Everything else
# (FIFOs, sockets, block/char devices, and anything lstat() doesn't
# recognize) is classified and excluded without ever being opened - a
# FIFO with no writer would otherwise block open() for read forever, and
# reading a device node can return unbounded or meaningless data.
_SUPPORTED_KINDS = frozenset({"file", "dir", "symlink"})
def entry_kind(entry: Union[os.DirEntry, "PseudoDirEntry"]) -> str:
"""
Classify a filesystem entry via lstat() mode bits alone - never
opens it, so classifying a FIFO can't block even without a writer.
:return: one of "file", "dir", "symlink", "fifo", "socket",
"block_device", "char_device", "unknown".
"""
mode = entry.stat(follow_symlinks=False).st_mode
if stat.S_ISLNK(mode):
return "symlink"
if stat.S_ISDIR(mode):
return "dir"
if stat.S_ISREG(mode):
return "file"
if stat.S_ISFIFO(mode):
return "fifo"
if stat.S_ISSOCK(mode):
return "socket"
if stat.S_ISBLK(mode):
return "block_device"
if stat.S_ISCHR(mode):
return "char_device"
return "unknown"
class PseudoDirEntry:
@@ -202,15 +237,19 @@ def scantree(path, dir_first=True) -> Iterable[os.DirEntry]:
def rm_direntry(entry: Union[os.DirEntry, PseudoDirEntry]):
""" Recursively delete DirEntry (dir/file/symlink). """
if entry.is_file(follow_symlinks=False) or entry.is_symlink():
os.unlink(entry.path)
elif entry.is_dir(follow_symlinks=False):
"""
Recursively delete DirEntry. Directories are removed recursively;
anything else (file, symlink, or a special entry like a FIFO,
socket, or device node) is unlinked directly.
"""
if entry.is_dir(follow_symlinks=False):
with os.scandir(entry.path) as it:
child_entry: os.DirEntry
for child_entry in it:
rm_direntry(child_entry)
os.rmdir(entry.path)
else:
os.unlink(entry.path)
try:
@@ -429,6 +468,21 @@ def rsync(src_dir,
del src_files_map[rel_path]
src_entry: os.DirEntry
src_kind = entry_kind(src_entry)
if src_kind not in _SUPPORTED_KINDS:
_lg.warning("Rsync, excluding unsupported entry (%s): %s",
src_kind, rel_path)
# dst_entry may exist here (e.g. inherited via hardlink from
# the previous snapshot); an excluded entry must not linger.
if not dry_run:
try:
rm_direntry(dst_entry)
except OSError as exc:
yield rel_path, Actions.ERROR, str(exc)
continue
yield rel_path, Actions.EXCLUDE, src_kind
continue
# rewrite dst if it has different type from src
if src_entry.is_file(follow_symlinks=False):
if not dst_entry.is_file(follow_symlinks=False):
@@ -543,6 +597,13 @@ def rsync(src_dir,
# process remained source entries (new files/dirs/symlinks)
for rel_path, src_entry in src_files_map.items():
src_kind = entry_kind(src_entry)
if src_kind not in _SUPPORTED_KINDS:
_lg.warning("Rsync, excluding unsupported entry (%s): %s",
src_kind, rel_path)
yield rel_path, Actions.EXCLUDE, src_kind
continue
dst_path = os.path.join(dst_root_abs, rel_path)
_lg.debug("Rsync, creating: %s", rel_path)
try: