Files
romm/backend/tests/utils/test_filesystem.py
Georges-Antoine Assi e657948bba fix trunk
2026-06-18 21:17:47 -04:00

219 lines
8.1 KiB
Python

"""Tests for utils.filesystem helpers."""
import errno
import os
from unittest.mock import patch
import pytest
from hypothesis import assume, given
from hypothesis import strategies as st
from utils.filesystem import link_or_copy_file, sanitize_filename
INVALID_AFTER_SANITIZE = set('\\/:|*?"<>+\0')
class TestLinkOrCopyFile:
"""Test the hardlink-with-copy-fallback helper used by importers and exporters."""
def test_same_filesystem_creates_hardlink(self, tmp_path):
"""When source and dest are on the same filesystem, the helper creates a
hardlink — source and dest share an inode and st_nlink reflects both."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
link_or_copy_file(source, dest)
assert dest.read_bytes() == b"payload"
# Hardlink: shared inode, link count >= 2
assert source.stat().st_ino == dest.stat().st_ino
assert source.stat().st_nlink >= 2
def test_falls_back_to_copy_on_exdev(self, tmp_path):
"""When os.link raises EXDEV (cross-device), the helper falls back to
shutil.copy2 — content matches but inodes differ."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
exdev = OSError(errno.EXDEV, "Cross-device link")
with patch("utils.filesystem.os.link", side_effect=exdev):
link_or_copy_file(source, dest)
assert dest.read_bytes() == b"payload"
# Real copy: separate inodes
assert source.stat().st_ino != dest.stat().st_ino
def test_falls_back_to_copy_on_eperm(self, tmp_path):
"""EPERM (filesystem doesn't permit hardlinks, e.g. FAT32) must also
trigger the copy fallback."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
eperm = OSError(errno.EPERM, "Operation not permitted")
with patch("utils.filesystem.os.link", side_effect=eperm):
link_or_copy_file(source, dest)
assert dest.read_bytes() == b"payload"
assert source.stat().st_ino != dest.stat().st_ino
def test_reraises_non_fallback_oserror(self, tmp_path):
"""An OSError that isn't in the fallback set (e.g. ENOSPC — disk full)
must propagate; we don't want to mask real disk errors."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
enospc = OSError(errno.ENOSPC, "No space left on device")
with patch("utils.filesystem.os.link", side_effect=enospc):
with pytest.raises(OSError) as excinfo:
link_or_copy_file(source, dest)
assert excinfo.value.errno == errno.ENOSPC
assert not dest.exists()
def test_mutation_after_link_affects_source(self, tmp_path):
"""Document hardlink semantics: writing through the dest path with
O_TRUNC truncates the shared inode and therefore mutates the source.
This is why callers that mutate dest in place (e.g. PIL resize) must
not opt into hardlinking — copy_file defaults to allow_link=False to
keep this hazard from being the easy path."""
source = tmp_path / "source.bin"
source.write_bytes(b"original")
dest = tmp_path / "dest.bin"
link_or_copy_file(source, dest)
# Truncating-write through dest affects source (same inode).
with open(dest, "wb") as f:
f.write(b"mutated")
assert source.read_bytes() == b"mutated"
def test_mutation_after_copy_does_not_affect_source(self, tmp_path):
"""When the helper falls back to copy, the inodes are independent —
mutation through dest leaves the source untouched."""
source = tmp_path / "source.bin"
source.write_bytes(b"original")
dest = tmp_path / "dest.bin"
with patch(
"utils.filesystem.os.link",
side_effect=OSError(errno.EXDEV, "Cross-device link"),
):
link_or_copy_file(source, dest)
with open(dest, "wb") as f:
f.write(b"mutated")
assert source.read_bytes() == b"original"
def test_dest_already_exists_is_overwritten(self, tmp_path):
"""When dest already exists, link_or_copy_file atomically replaces it.
This mirrors shutil.copy2's overwrite-on-exists semantics so callers
re-fetching a resource don't have to pre-unlink to avoid EEXIST."""
source = tmp_path / "source.bin"
source.write_bytes(b"new payload")
dest = tmp_path / "dest.bin"
dest.write_bytes(b"old payload")
link_or_copy_file(source, dest)
assert dest.read_bytes() == b"new payload"
# Same-fs: dest is now a hardlink to source.
assert source.stat().st_ino == dest.stat().st_ino
def test_dest_already_exists_is_overwritten_on_copy_fallback(self, tmp_path):
"""Overwrite semantics also hold when the link fails and we fall back
to copy — replace happens after the tempfile is populated either way."""
source = tmp_path / "source.bin"
source.write_bytes(b"new payload")
dest = tmp_path / "dest.bin"
dest.write_bytes(b"old payload")
with patch(
"utils.filesystem.os.link",
side_effect=OSError(errno.EXDEV, "Cross-device link"),
):
link_or_copy_file(source, dest)
assert dest.read_bytes() == b"new payload"
# Fell back to copy: separate inodes.
assert source.stat().st_ino != dest.stat().st_ino
def test_tempfile_cleaned_up_on_failure(self, tmp_path):
"""If linking and the copy fallback both fail (e.g. ENOSPC), the helper
must not leave a stray tempfile behind in dest's directory."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
enospc = OSError(errno.ENOSPC, "No space left on device")
with patch("utils.filesystem.os.link", side_effect=enospc):
with pytest.raises(OSError) as excinfo:
link_or_copy_file(source, dest)
assert excinfo.value.errno == errno.ENOSPC
assert not dest.exists()
# No leftover .romm_link_tmp_* file in the directory.
leftovers = [
p for p in tmp_path.iterdir() if p.name.startswith(".romm_link_tmp_")
]
assert leftovers == []
def test_helper_uses_os_link_first(self, tmp_path):
"""Sanity-check that the helper calls os.link before any copy logic —
guards against a future refactor regressing to copy-only. The second
arg is a tempfile in dest's parent (not dest itself), since we
link-then-replace for atomic overwrite."""
source = tmp_path / "source.bin"
source.write_bytes(b"payload")
dest = tmp_path / "dest.bin"
with patch("utils.filesystem.os.link", wraps=os.link) as link_spy:
link_or_copy_file(source, dest)
link_spy.assert_called_once()
args = link_spy.call_args.args
assert args[0] == source
assert args[1].parent == dest.parent
assert args[1].name.startswith(".romm_link_tmp_")
class TestSanitizeFilename:
"""Property-based tests for the cross-filesystem filename sanitizer."""
def _sanitize_or_none(self, name: str) -> str | None:
try:
return sanitize_filename(name)
except ValueError:
return None
@given(st.text())
def test_output_contains_no_invalid_characters(self, name: str):
result = self._sanitize_or_none(name)
assume(result is not None)
assert result is not None
assert not (set(result) & INVALID_AFTER_SANITIZE)
@given(st.text())
def test_output_is_stripped(self, name: str):
result = self._sanitize_or_none(name)
assume(result is not None)
assert result is not None
assert result == result.strip()
assert result != ""
@given(st.text())
def test_is_idempotent(self, name: str):
once = self._sanitize_or_none(name)
assume(once is not None)
assert once is not None
assert sanitize_filename(once) == once