2026-02-04 20:23:51 -08:00
|
|
|
import os
|
|
|
|
|
import os.path
|
|
|
|
|
import socket
|
|
|
|
|
import string
|
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from curateipsum import fs
|
|
|
|
|
from conftest import create_file, create_dir, relpath, check_identical_file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MockStdout:
|
|
|
|
|
"""Mock stdout that supports context manager and readline iteration."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, lines):
|
|
|
|
|
self.lines = iter(lines)
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *args):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def readline(self):
|
|
|
|
|
try:
|
|
|
|
|
return next(self.lines)
|
|
|
|
|
except StopIteration:
|
|
|
|
|
return b""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRsync:
|
|
|
|
|
def test_dst_has_excess_file(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_fpath = create_file(str(dst_dir))
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert not os.path.lexists(dst_fpath)
|
|
|
|
|
|
|
|
|
|
def test_dst_has_excess_symlink(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_lpath = os.path.join(str(dst_dir), 'nonexisting_file')
|
|
|
|
|
os.symlink('broken_symlink', dst_lpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert not os.path.lexists(dst_lpath)
|
|
|
|
|
|
|
|
|
|
def test_dst_has_excess_empty_dir(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_dpath = create_dir(str(dst_dir))
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert not os.path.lexists(dst_dpath)
|
|
|
|
|
|
|
|
|
|
def test_dst_has_excess_nonempty_dir(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_dpath = create_dir(str(dst_dir))
|
|
|
|
|
create_file(dst_dpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert not os.path.lexists(dst_dpath)
|
|
|
|
|
|
|
|
|
|
def test_dst_has_excess_nonempty_recursive_dir(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_dpath = create_dir(str(dst_dir))
|
|
|
|
|
nested_dpath = create_dir(dst_dpath)
|
|
|
|
|
create_file(nested_dpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert not os.path.lexists(dst_dpath)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_file_dst_dir(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_fpath = create_file(str(src_dir))
|
|
|
|
|
dst_path = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_fpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.mkdir(dst_path)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.isfile(dst_path)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_file_dst_symlink(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_fpath = create_file(str(src_dir))
|
|
|
|
|
dst_path = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_fpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.symlink('broken_link', dst_path)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.isfile(dst_path)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_symlink_dst_file(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_path = create_file(str(dst_dir))
|
|
|
|
|
src_lpath = os.path.join(str(src_dir),
|
|
|
|
|
relpath(dst_path, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.symlink('broken_link', src_lpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.islink(dst_path)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_symlink_dst_dir(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
dst_path = create_dir(str(dst_dir))
|
|
|
|
|
src_lpath = os.path.join(str(src_dir),
|
|
|
|
|
relpath(dst_path, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.symlink('broken_link', src_lpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.islink(dst_path)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_dir_dst_file(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_dpath = create_dir(str(src_dir))
|
|
|
|
|
dst_path = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_dpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
with open(dst_path, "w") as f:
|
|
|
|
|
f.write(string.printable)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.isdir(dst_path)
|
|
|
|
|
|
|
|
|
|
def test_different_types_src_dir_dst_symlink(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_dpath = create_dir(str(src_dir))
|
|
|
|
|
dst_path = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_dpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.symlink('broken_link', dst_path)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_path)
|
|
|
|
|
assert os.path.isdir(dst_path)
|
|
|
|
|
|
2026-08-04 22:52:42 +00:00
|
|
|
@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"),
|
|
|
|
|
reason="AF_UNIX not available on this platform")
|
|
|
|
|
def test_src_is_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:
|
|
|
|
|
src_dir = os.path.join(d, "src")
|
|
|
|
|
dst_dir = os.path.join(d, "dst")
|
|
|
|
|
os.mkdir(src_dir)
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
src_spath = os.path.join(src_dir, "a_socket")
|
|
|
|
|
dst_spath = os.path.join(dst_dir, "a_socket")
|
|
|
|
|
with socket.socket(socket.AF_UNIX) as sock:
|
|
|
|
|
sock.bind(src_spath)
|
|
|
|
|
all(fs.rsync(src_dir, dst_dir))
|
2026-02-04 20:23:51 -08:00
|
|
|
|
2026-08-04 22:52:42 +00:00
|
|
|
assert not os.path.lexists(dst_spath)
|
2026-02-04 20:23:51 -08:00
|
|
|
|
|
|
|
|
def test_src_dst_same_inode(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_fpath = create_file(str(src_dir))
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_fpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
os.link(src_fpath, dst_fpath)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_fpath)
|
|
|
|
|
src_stat = os.lstat(src_fpath)
|
|
|
|
|
dst_stat = os.lstat(dst_fpath)
|
|
|
|
|
assert src_stat.st_nlink == 1
|
|
|
|
|
assert dst_stat.st_nlink == 1
|
|
|
|
|
assert src_stat.st_ino != dst_stat.st_ino
|
|
|
|
|
|
|
|
|
|
def test_src_dst_diff_size(self, common_fs_dirs):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_fpath = create_file(str(src_dir))
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_fpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
with open(dst_fpath, "w") as df:
|
|
|
|
|
df.write(string.printable * 2)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
assert os.path.lexists(dst_fpath)
|
2026-02-04 22:00:55 -08:00
|
|
|
check_identical_file(src_fpath, dst_fpath)
|
|
|
|
|
|
|
|
|
|
def test_broken_symlink_in_source(self, common_fs_dirs):
|
|
|
|
|
"""Test that rsync copies broken symlinks correctly."""
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_lpath = os.path.join(str(src_dir), 'broken_link')
|
|
|
|
|
# create symlink pointing to non-existent target
|
|
|
|
|
os.symlink('/nonexistent/target/file.txt', src_lpath)
|
|
|
|
|
dst_lpath = os.path.join(str(dst_dir), 'broken_link')
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
# verify symlink was copied and remains broken
|
|
|
|
|
assert os.path.islink(dst_lpath)
|
|
|
|
|
assert os.readlink(dst_lpath) == '/nonexistent/target/file.txt'
|
|
|
|
|
assert not os.path.exists(dst_lpath) # link target doesn't exist
|
|
|
|
|
|
|
|
|
|
def test_unicode_filename(self, common_fs_dirs):
|
|
|
|
|
"""Test that rsync handles unicode characters in filenames."""
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
# use various unicode characters: emoji, cyrillic, chinese, accents
|
|
|
|
|
unicode_name = 'test_文件_файл_café_🎉.txt'
|
|
|
|
|
src_fpath = os.path.join(str(src_dir), unicode_name)
|
|
|
|
|
with open(src_fpath, "w", encoding='utf-8') as f:
|
|
|
|
|
f.write("unicode content: 你好世界")
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir), unicode_name)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(dst_fpath)
|
|
|
|
|
with open(dst_fpath, "r", encoding='utf-8') as f:
|
|
|
|
|
assert f.read() == "unicode content: 你好世界"
|
|
|
|
|
|
|
|
|
|
def test_filename_with_spaces(self, common_fs_dirs):
|
|
|
|
|
"""Test that rsync handles filenames with spaces."""
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
spaced_name = 'file with many spaces.txt'
|
|
|
|
|
src_fpath = os.path.join(str(src_dir), spaced_name)
|
|
|
|
|
with open(src_fpath, "w") as f:
|
|
|
|
|
f.write("spaced content")
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir), spaced_name)
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(dst_fpath)
|
|
|
|
|
check_identical_file(src_fpath, dst_fpath)
|
|
|
|
|
|
|
|
|
|
def test_empty_file(self, common_fs_dirs):
|
|
|
|
|
"""Test that rsync handles empty (0 byte) files correctly."""
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
src_fpath = os.path.join(str(src_dir), 'empty.txt')
|
|
|
|
|
# create empty file
|
|
|
|
|
open(src_fpath, "w").close()
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir), 'empty.txt')
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(dst_fpath)
|
|
|
|
|
assert os.path.getsize(dst_fpath) == 0
|
|
|
|
|
|
|
|
|
|
def test_very_long_relative_path(self, common_fs_dirs):
|
|
|
|
|
"""Test that rsync handles deeply nested directory structures."""
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
# create a deeply nested path (10 levels deep)
|
|
|
|
|
nested_path = os.path.join(*[f"level{i}" for i in range(10)])
|
|
|
|
|
src_nested = os.path.join(str(src_dir), nested_path)
|
|
|
|
|
os.makedirs(src_nested, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# create a file in the deepest directory
|
|
|
|
|
src_fpath = os.path.join(src_nested, 'deep_file.txt')
|
|
|
|
|
with open(src_fpath, "w") as f:
|
|
|
|
|
f.write("deeply nested content")
|
|
|
|
|
|
|
|
|
|
dst_nested = os.path.join(str(dst_dir), nested_path)
|
|
|
|
|
dst_fpath = os.path.join(dst_nested, 'deep_file.txt')
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(dst_fpath)
|
2026-02-04 20:23:51 -08:00
|
|
|
check_identical_file(src_fpath, dst_fpath)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRsyncBasic:
|
|
|
|
|
"""Test suite for basic rsync functionality."""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def rsync_dirs(self, tmp_path):
|
|
|
|
|
"""Create source and destination directories for rsync tests."""
|
|
|
|
|
src_dir = os.path.join(str(tmp_path), "source")
|
|
|
|
|
dst_dir = os.path.join(str(tmp_path), "dest")
|
|
|
|
|
os.mkdir(src_dir)
|
|
|
|
|
return src_dir, dst_dir
|
|
|
|
|
|
|
|
|
|
def test_rsync_creates_destination(self, rsync_dirs):
|
|
|
|
|
"""Test that rsync creates destination directory if missing"""
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
assert not os.path.exists(dst_dir)
|
|
|
|
|
list(fs.rsync(src_dir, dst_dir))
|
|
|
|
|
assert os.path.isdir(dst_dir)
|
|
|
|
|
|
|
|
|
|
def test_rsync_copies_new_files(self, rsync_dirs):
|
|
|
|
|
"""Test that rsync copies new files"""
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
|
|
|
|
|
with open(os.path.join(src_dir, "file1.txt"), "w") as f:
|
|
|
|
|
f.write("content1")
|
|
|
|
|
with open(os.path.join(src_dir, "file2.txt"), "w") as f:
|
|
|
|
|
f.write("content2")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(os.path.join(dst_dir, "file1.txt"))
|
|
|
|
|
assert os.path.exists(os.path.join(dst_dir, "file2.txt"))
|
|
|
|
|
|
|
|
|
|
create_actions = [a for a in actions if a[1] == fs.Actions.CREATE]
|
|
|
|
|
assert len(create_actions) == 2
|
|
|
|
|
|
|
|
|
|
def test_rsync_deletes_missing_files(self, rsync_dirs):
|
|
|
|
|
"""Test that rsync deletes files not in source"""
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
|
|
|
|
|
dst_file = os.path.join(dst_dir, "old_file.txt")
|
|
|
|
|
with open(dst_file, "w") as f:
|
|
|
|
|
f.write("old content")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir))
|
|
|
|
|
|
|
|
|
|
assert not os.path.exists(dst_file)
|
|
|
|
|
|
|
|
|
|
delete_actions = [a for a in actions if a[1] == fs.Actions.DELETE]
|
|
|
|
|
assert len(delete_actions) == 1
|
|
|
|
|
|
|
|
|
|
def test_rsync_updates_modified_files(self, rsync_dirs):
|
|
|
|
|
"""Test that rsync updates modified files"""
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
|
|
|
|
|
src_file = os.path.join(src_dir, "file.txt")
|
|
|
|
|
dst_file = os.path.join(dst_dir, "file.txt")
|
|
|
|
|
|
|
|
|
|
with open(src_file, "w") as f:
|
|
|
|
|
f.write("original")
|
|
|
|
|
with open(dst_file, "w") as f:
|
|
|
|
|
f.write("modified")
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
with open(src_file, "w") as f:
|
|
|
|
|
f.write("updated content")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir))
|
|
|
|
|
|
|
|
|
|
with open(dst_file, "r") as f:
|
|
|
|
|
assert f.read() == "updated content"
|
|
|
|
|
|
|
|
|
|
rewrite_actions = [a for a in actions if a[1] == fs.Actions.REWRITE]
|
|
|
|
|
assert len(rewrite_actions) > 0
|
|
|
|
|
|
|
|
|
|
def test_rsync_preserves_permissions(self, rsync_dirs):
|
|
|
|
|
"""Test that rsync preserves file permissions"""
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
|
|
|
|
|
src_file = os.path.join(src_dir, "script.sh")
|
|
|
|
|
with open(src_file, "w") as f:
|
|
|
|
|
f.write("#!/bin/bash\n")
|
|
|
|
|
os.chmod(src_file, 0o755)
|
|
|
|
|
|
|
|
|
|
list(fs.rsync(src_dir, dst_dir))
|
|
|
|
|
|
|
|
|
|
dst_file = os.path.join(dst_dir, "script.sh")
|
|
|
|
|
dst_stat = os.stat(dst_file)
|
|
|
|
|
src_stat = os.stat(src_file)
|
|
|
|
|
assert dst_stat.st_mode == src_stat.st_mode
|
|
|
|
|
|
|
|
|
|
|
2026-08-09 01:39:53 +00:00
|
|
|
class TestRsyncDryRun:
|
|
|
|
|
"""
|
|
|
|
|
fs.rsync(dry_run=True) must yield the same actions a real run would,
|
|
|
|
|
while never creating, deleting, or modifying anything - including
|
|
|
|
|
dst_dir itself when it doesn't already exist.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def rsync_dirs(self, tmp_path):
|
|
|
|
|
src_dir = os.path.join(str(tmp_path), "source")
|
|
|
|
|
dst_dir = os.path.join(str(tmp_path), "dest")
|
|
|
|
|
os.mkdir(src_dir)
|
|
|
|
|
return src_dir, dst_dir
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_create_missing_destination(self, rsync_dirs):
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
with open(os.path.join(src_dir, "file.txt"), "w") as f:
|
|
|
|
|
f.write("content")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
|
|
|
|
|
|
|
|
|
|
assert not os.path.exists(dst_dir)
|
|
|
|
|
assert [a[1] for a in actions] == [fs.Actions.CREATE]
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_copy_new_files(self, rsync_dirs):
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
with open(os.path.join(src_dir, "file.txt"), "w") as f:
|
|
|
|
|
f.write("content")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
|
|
|
|
|
|
|
|
|
|
assert not os.path.exists(os.path.join(dst_dir, "file.txt"))
|
|
|
|
|
assert [a[1] for a in actions] == [fs.Actions.CREATE]
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_delete_stale_files(self, rsync_dirs):
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
stale = os.path.join(dst_dir, "stale.txt")
|
|
|
|
|
with open(stale, "w") as f:
|
|
|
|
|
f.write("stale")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
|
|
|
|
|
|
|
|
|
|
assert os.path.exists(stale)
|
|
|
|
|
assert [a[1] for a in actions] == [fs.Actions.DELETE]
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_rewrite_changed_files(self, rsync_dirs):
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
src_file = os.path.join(src_dir, "file.txt")
|
|
|
|
|
dst_file = os.path.join(dst_dir, "file.txt")
|
|
|
|
|
with open(src_file, "w") as f:
|
|
|
|
|
f.write("updated content")
|
|
|
|
|
with open(dst_file, "w") as f:
|
|
|
|
|
f.write("modified")
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
|
|
|
|
|
|
|
|
|
|
with open(dst_file, "r") as f:
|
|
|
|
|
assert f.read() == "modified"
|
|
|
|
|
assert [a[1] for a in actions] == [fs.Actions.REWRITE]
|
|
|
|
|
|
|
|
|
|
def test_dry_run_does_not_change_permissions(self, rsync_dirs):
|
|
|
|
|
src_dir, dst_dir = rsync_dirs
|
|
|
|
|
os.mkdir(dst_dir)
|
|
|
|
|
src_file = os.path.join(src_dir, "script.sh")
|
|
|
|
|
dst_file = os.path.join(dst_dir, "script.sh")
|
|
|
|
|
with open(src_file, "w") as f:
|
|
|
|
|
f.write("content")
|
|
|
|
|
with open(dst_file, "w") as f:
|
|
|
|
|
f.write("content")
|
|
|
|
|
os.utime(dst_file, ns=(os.stat(src_file).st_atime_ns,
|
|
|
|
|
os.stat(src_file).st_mtime_ns))
|
|
|
|
|
os.chmod(src_file, 0o755)
|
|
|
|
|
os.chmod(dst_file, 0o644)
|
|
|
|
|
|
|
|
|
|
actions = list(fs.rsync(src_dir, dst_dir, dry_run=True))
|
|
|
|
|
|
|
|
|
|
assert os.stat(dst_file).st_mode & 0o777 == 0o644
|
|
|
|
|
assert [a[1] for a in actions] == [fs.Actions.UPDATE_PERM]
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 20:23:51 -08:00
|
|
|
class TestParseRsyncOutput:
|
|
|
|
|
"""Test _parse_rsync_output() parsing of rsync --itemize-changes."""
|
|
|
|
|
|
|
|
|
|
def test_delete_file(self):
|
|
|
|
|
"""Parse deletion output."""
|
|
|
|
|
result = fs._parse_rsync_output("*deleting path/to/file.txt")
|
|
|
|
|
assert result == ("path/to/file.txt", fs.Actions.DELETE, "")
|
|
|
|
|
|
|
|
|
|
def test_delete_nested_path(self):
|
|
|
|
|
"""Parse deletion with nested directory structure."""
|
|
|
|
|
result = fs._parse_rsync_output("*deleting deeply/nested/dir/file.md")
|
|
|
|
|
assert result == ("deeply/nested/dir/file.md", fs.Actions.DELETE, "")
|
|
|
|
|
|
|
|
|
|
def test_create_file(self):
|
|
|
|
|
"""Parse file creation."""
|
|
|
|
|
result = fs._parse_rsync_output(">f+++++++++ new_file.txt")
|
|
|
|
|
assert result == ("new_file.txt", fs.Actions.CREATE, "")
|
|
|
|
|
|
|
|
|
|
def test_create_file_nested(self):
|
|
|
|
|
"""Parse file creation in subdirectory."""
|
|
|
|
|
result = fs._parse_rsync_output(">f+++++++++ subdir/another.log")
|
|
|
|
|
assert result == ("subdir/another.log", fs.Actions.CREATE, "")
|
|
|
|
|
|
|
|
|
|
def test_create_directory(self):
|
|
|
|
|
"""Parse directory creation."""
|
|
|
|
|
result = fs._parse_rsync_output("cd+++++++++ new_directory/")
|
|
|
|
|
assert result == ("new_directory/", fs.Actions.CREATE, "")
|
|
|
|
|
|
|
|
|
|
def test_create_symlink(self):
|
|
|
|
|
"""Parse symlink creation."""
|
|
|
|
|
result = fs._parse_rsync_output("cL+++++++++ link_to_file")
|
|
|
|
|
assert result == ("link_to_file", fs.Actions.CREATE, "")
|
|
|
|
|
|
|
|
|
|
def test_rewrite_file_size_change(self):
|
|
|
|
|
"""Parse file rewrite due to size change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f.s...... modified_file.txt")
|
|
|
|
|
assert result == ("modified_file.txt", fs.Actions.REWRITE, "")
|
|
|
|
|
|
|
|
|
|
def test_rewrite_file_time_change(self):
|
|
|
|
|
"""Parse file rewrite due to time change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f..t...... touched_file.dat")
|
|
|
|
|
assert result == ("touched_file.dat", fs.Actions.REWRITE, "")
|
|
|
|
|
|
|
|
|
|
def test_rewrite_file_size_and_time(self):
|
|
|
|
|
"""Parse file rewrite due to both size and time change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f.st...... changed.bin")
|
|
|
|
|
assert result == ("changed.bin", fs.Actions.REWRITE, "")
|
|
|
|
|
|
|
|
|
|
def test_update_directory_time(self):
|
|
|
|
|
"""Parse directory time update."""
|
|
|
|
|
result = fs._parse_rsync_output(">d..t...... some_dir/")
|
|
|
|
|
assert result == ("some_dir/", fs.Actions.UPDATE_TIME, "")
|
|
|
|
|
|
|
|
|
|
def test_update_permissions(self):
|
|
|
|
|
"""Parse permission change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f....p.... executable.sh")
|
|
|
|
|
assert result == ("executable.sh", fs.Actions.UPDATE_PERM, "")
|
|
|
|
|
|
|
|
|
|
def test_update_permissions_with_time_change(self):
|
|
|
|
|
"""Time change takes precedence over permission change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f...tp.... script.py")
|
|
|
|
|
assert result == ("script.py", fs.Actions.REWRITE, "")
|
|
|
|
|
|
|
|
|
|
def test_update_owner(self):
|
|
|
|
|
"""Parse owner change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f.....o... owned_file.txt")
|
|
|
|
|
assert result == ("owned_file.txt", fs.Actions.UPDATE_OWNER, "")
|
|
|
|
|
|
|
|
|
|
def test_update_group(self):
|
|
|
|
|
"""Parse group change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f......g.. grouped_file.txt")
|
|
|
|
|
assert result == ("grouped_file.txt", fs.Actions.UPDATE_OWNER, "")
|
|
|
|
|
|
|
|
|
|
def test_update_owner_and_group(self):
|
|
|
|
|
"""Parse both owner and group change."""
|
|
|
|
|
result = fs._parse_rsync_output(">f.....og.. shared_file.txt")
|
|
|
|
|
assert result == ("shared_file.txt", fs.Actions.UPDATE_OWNER, "")
|
|
|
|
|
|
|
|
|
|
def test_invalid_format_raises_error(self):
|
|
|
|
|
"""Unparseable line should raise RuntimeError."""
|
|
|
|
|
with pytest.raises(RuntimeError, match="Not parsed string"):
|
|
|
|
|
fs._parse_rsync_output(">f......... unknown.txt")
|
|
|
|
|
|
|
|
|
|
def test_empty_change_string_raises_error(self):
|
|
|
|
|
"""Empty change indicators should raise RuntimeError."""
|
|
|
|
|
with pytest.raises(RuntimeError, match="Not parsed string"):
|
|
|
|
|
fs._parse_rsync_output(">f......... no_action.txt")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRsyncExt:
|
|
|
|
|
"""Test external rsync command wrapper."""
|
|
|
|
|
|
|
|
|
|
def test_command_construction(self, tmp_path):
|
|
|
|
|
"""Verify rsync command is built with correct arguments."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
# minimal valid rsync output
|
|
|
|
|
mock_process.stdout = MockStdout([b">f+++++++++ test.txt\n"])
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
# verify command was called with correct arguments
|
|
|
|
|
args = mock_popen.call_args[0][0]
|
|
|
|
|
assert args[0] == "rsync"
|
|
|
|
|
assert "--archive" in args
|
|
|
|
|
assert "--whole-file" in args
|
|
|
|
|
assert "--human-readable" in args
|
|
|
|
|
assert "--delete-during" in args
|
|
|
|
|
assert "--itemize-changes" in args
|
2026-08-06 03:38:16 +00:00
|
|
|
assert "--one-file-system" in args
|
2026-02-04 20:23:51 -08:00
|
|
|
assert args[-2] == f"{src}/"
|
|
|
|
|
assert args[-1] == str(dst)
|
|
|
|
|
|
|
|
|
|
def test_dry_run_flag(self, tmp_path):
|
|
|
|
|
"""Verify --dry-run flag is added when dry_run=True."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout([b">f+++++++++ test.txt\n"])
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
list(fs.rsync_ext(str(src), str(dst), dry_run=True))
|
|
|
|
|
|
|
|
|
|
args = mock_popen.call_args[0][0]
|
|
|
|
|
assert "--dry-run" in args
|
|
|
|
|
|
|
|
|
|
def test_processes_rsync_output_lines(self, tmp_path):
|
|
|
|
|
"""Parse valid rsync itemize output lines."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
rsync_output = [
|
|
|
|
|
b"cd+++++++++ subdir/\n",
|
|
|
|
|
b">f+++++++++ subdir/newfile.txt\n",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout(rsync_output)
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
results = list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
# both lines should be processed
|
|
|
|
|
assert len(results) == 2
|
|
|
|
|
assert results[0][0] == "subdir/"
|
|
|
|
|
assert results[0][1] == fs.Actions.CREATE
|
|
|
|
|
assert results[1][0] == "subdir/newfile.txt"
|
|
|
|
|
assert results[1][1] == fs.Actions.CREATE
|
|
|
|
|
|
|
|
|
|
def test_handles_unicode_decode_error(self, tmp_path, caplog):
|
|
|
|
|
"""Log error when encountering non-UTF8 bytes."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
# invalid UTF-8 byte sequence
|
|
|
|
|
invalid_utf8 = b"\xff\xfe invalid bytes\n"
|
|
|
|
|
rsync_output = [
|
|
|
|
|
invalid_utf8,
|
|
|
|
|
b">f+++++++++ validfile.txt\n",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout(rsync_output)
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
results = list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
# current behavior: invalid line causes prev_line to get stuck,
|
|
|
|
|
# so subsequent lines are also skipped (potential bug)
|
|
|
|
|
assert len(results) == 0
|
|
|
|
|
assert "Can't process rsync line" in caplog.text
|
|
|
|
|
|
|
|
|
|
def test_yields_parsed_actions(self, tmp_path):
|
|
|
|
|
"""Verify integration with _parse_rsync_output()."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
rsync_output = [
|
|
|
|
|
b"cd+++++++++ dir1/\n",
|
|
|
|
|
b">f+++++++++ file1.txt\n",
|
|
|
|
|
b"*deleting oldfile.txt\n",
|
|
|
|
|
b">f..t...... file2.txt\n",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout(rsync_output)
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
results = list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
assert len(results) == 4
|
|
|
|
|
assert results[0] == ("dir1/", fs.Actions.CREATE, "")
|
|
|
|
|
assert results[1] == ("file1.txt", fs.Actions.CREATE, "")
|
|
|
|
|
assert results[2] == ("oldfile.txt", fs.Actions.DELETE, "")
|
|
|
|
|
assert results[3] == ("file2.txt", fs.Actions.REWRITE, "")
|
|
|
|
|
|
|
|
|
|
def test_calls_process_wait(self, tmp_path):
|
|
|
|
|
"""Ensure subprocess.wait() is called to clean up process."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout([b">f+++++++++ test.txt\n"])
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
mock_process.wait.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_single_line_output(self, tmp_path):
|
|
|
|
|
"""Handle case where rsync produces single file output."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout([b">f+++++++++ newfile.txt\n"])
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
results = list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
assert len(results) == 1
|
|
|
|
|
assert results[0][0] == "newfile.txt"
|
|
|
|
|
assert results[0][1] == fs.Actions.CREATE
|
|
|
|
|
|
|
|
|
|
def test_multiple_files_in_output(self, tmp_path):
|
|
|
|
|
"""Process multiple file operations in single rsync run."""
|
|
|
|
|
src = tmp_path / "source"
|
|
|
|
|
dst = tmp_path / "dest"
|
|
|
|
|
src.mkdir()
|
|
|
|
|
dst.mkdir()
|
|
|
|
|
|
|
|
|
|
# realistic rsync output with multiple operations
|
|
|
|
|
rsync_output = [
|
|
|
|
|
b"cd+++++++++ docs/\n",
|
|
|
|
|
b">f+++++++++ docs/readme.md\n",
|
|
|
|
|
b">f+++++++++ config.json\n",
|
|
|
|
|
b".f..t...... existing.txt\n",
|
|
|
|
|
b"*deleting old/\n",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with patch("subprocess.Popen") as mock_popen:
|
|
|
|
|
mock_process = Mock()
|
|
|
|
|
mock_process.stdout = MockStdout(rsync_output)
|
|
|
|
|
mock_process.wait.return_value = 0
|
|
|
|
|
mock_popen.return_value = mock_process
|
|
|
|
|
|
|
|
|
|
results = list(fs.rsync_ext(str(src), str(dst)))
|
|
|
|
|
|
|
|
|
|
assert len(results) == 5
|
|
|
|
|
assert results[0][0] == "docs/"
|
|
|
|
|
assert results[1][0] == "docs/readme.md"
|
|
|
|
|
assert results[2][0] == "config.json"
|
|
|
|
|
assert results[3][0] == "existing.txt"
|
|
|
|
|
assert results[4][0] == "old/"
|
2026-08-03 23:08:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRsyncHardlinkImmutability:
|
|
|
|
|
"""
|
|
|
|
|
Regression tests: rsync() is run against a staging directory that
|
|
|
|
|
was populated by hardlinking in the previous, completed snapshot
|
|
|
|
|
(curateipsum.backup.hardlink_dir). Any in-place metadata change made
|
|
|
|
|
while updating staging must never mutate that previous snapshot's
|
|
|
|
|
files via the shared inode.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_permission_change_does_not_mutate_shared_hardlink(
|
|
|
|
|
self, common_fs_dirs, tmp_path
|
|
|
|
|
):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
# Independent source file with new content/mtime but the SAME
|
|
|
|
|
# eventual permissions the old snapshot file already has - only
|
|
|
|
|
# the fallback "update permissions and ownership" branch should
|
|
|
|
|
# fire, not a full rewrite (which would already be safe via
|
|
|
|
|
# rm+recreate).
|
|
|
|
|
src_fpath = create_file(str(src_dir))
|
|
|
|
|
os.chmod(src_fpath, 0o600)
|
|
|
|
|
|
|
|
|
|
# Simulate staging (dst_dir) having been hardlinked in from the
|
|
|
|
|
# previous, completed snapshot: a separate directory tree outside
|
|
|
|
|
# dst_dir, sharing an inode with the staging file, with content
|
|
|
|
|
# and mtime matching src (so only permissions differ) but the old
|
|
|
|
|
# mode.
|
|
|
|
|
dst_fpath = os.path.join(str(dst_dir),
|
|
|
|
|
relpath(src_fpath, str(src_dir), str(dst_dir)))
|
|
|
|
|
prior_snapshot_fpath = str(tmp_path / "prior_snapshot")
|
|
|
|
|
with open(src_fpath) as src_f:
|
|
|
|
|
src_content = src_f.read()
|
|
|
|
|
with open(prior_snapshot_fpath, "w") as f:
|
|
|
|
|
f.write(src_content)
|
|
|
|
|
os.chmod(prior_snapshot_fpath, 0o644)
|
|
|
|
|
src_stat = os.lstat(src_fpath)
|
|
|
|
|
os.utime(prior_snapshot_fpath, (src_stat.st_atime, src_stat.st_mtime))
|
|
|
|
|
os.link(prior_snapshot_fpath, dst_fpath)
|
|
|
|
|
prior_mode_before = os.lstat(prior_snapshot_fpath).st_mode
|
|
|
|
|
dst_mtime_before = os.lstat(dst_fpath).st_mtime
|
|
|
|
|
assert os.lstat(dst_fpath).st_ino == os.lstat(
|
|
|
|
|
prior_snapshot_fpath).st_ino # sanity: genuinely shared
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.lstat(dst_fpath).st_mode == os.lstat(src_fpath).st_mode
|
|
|
|
|
assert os.lstat(prior_snapshot_fpath).st_mode == prior_mode_before
|
|
|
|
|
# dst must now have its own inode, no longer shared
|
|
|
|
|
assert os.lstat(dst_fpath).st_ino != os.lstat(
|
|
|
|
|
prior_snapshot_fpath).st_ino
|
|
|
|
|
# breaking the hardlink must not, as a side effect, reset mtime:
|
|
|
|
|
# rsync only intended to change permissions here (content/mtime
|
|
|
|
|
# already matched), and uid/gid are identical for this test user
|
|
|
|
|
# so the chown branch never fires to "accidentally" fix ownership
|
|
|
|
|
assert os.lstat(dst_fpath).st_mtime == dst_mtime_before
|
|
|
|
|
|
|
|
|
|
def test_symlink_metadata_never_touches_external_target(
|
|
|
|
|
self, common_fs_dirs, tmp_path
|
|
|
|
|
):
|
|
|
|
|
src_dir, dst_dir = common_fs_dirs
|
|
|
|
|
external_target = tmp_path / "external_target"
|
|
|
|
|
external_target.mkdir()
|
|
|
|
|
(external_target / "canary").write_text("do-not-touch")
|
|
|
|
|
external_mode_before = os.lstat(str(external_target)).st_mode
|
|
|
|
|
|
|
|
|
|
link_name = "a_symlink"
|
|
|
|
|
os.symlink(str(external_target), os.path.join(str(src_dir), link_name))
|
|
|
|
|
os.symlink(str(external_target), os.path.join(str(dst_dir), link_name))
|
|
|
|
|
|
|
|
|
|
all(fs.rsync(str(src_dir), str(dst_dir)))
|
|
|
|
|
|
|
|
|
|
assert os.lstat(str(external_target)).st_mode == external_mode_before
|
|
|
|
|
assert (external_target / "canary").read_text() == "do-not-touch"
|
2026-08-04 03:25:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"))
|