Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,12 +700,12 @@ def preserve_file_attributes(

_copy_extended_attributes(source, dest)

os.utime(dest, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns))
_restore_timestamps(source_stat, dest)

resolved_setfile = setfile_path if setfile_path is not None else _get_setfile_path()
if resolved_setfile:
_copy_macos_creation_time(source_stat, dest, resolved_setfile)
os.utime(dest, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns))
_restore_timestamps(source_stat, dest)


def convert_file(
Expand Down Expand Up @@ -1630,6 +1630,19 @@ def _resolve_collision(path: Path, *, overwrite: bool) -> Path:
raise FileExistsError(f"Could not find free output path for {path}")


def _restore_timestamps(source_stat: os.stat_result, dest: Path) -> None:
"""Best-effort copy of nanosecond atime/mtime from source_stat onto dest.

A read-only destination or a filesystem lacking timestamp support can make
os.utime raise OSError; that is non-critical metadata, so the failure is
swallowed to keep attribute preservation best-effort.
"""
try:
os.utime(dest, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns))
except OSError:
pass


def _copy_extended_attributes(source: Path, dest: Path) -> None:
"""Copy extended attributes from source to dest if supported by OS."""
if not all(hasattr(os, attr) for attr in ("listxattr", "getxattr", "setxattr")):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_media_shrinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1914,3 +1914,32 @@ class MockStat:
with patch("media_shrinker._copy_macos_creation_time"):
with patch("media_shrinker._get_setfile_path", return_value="/bin/echo"):
preserve_file_attributes(src, dest)

def test_preserve_file_attributes_ignores_utime_error(self) -> None:
"""os.utime failure must not abort best-effort metadata copy.

A read-only or timestamp-unsupporting destination filesystem can make
os.utime raise OSError. The documented contract is best-effort, so the
completed conversion must not be lost and the macOS creation-time step
must still run.
"""
from media_shrinker import preserve_file_attributes

with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.txt"
src.write_text("hello")
dest = Path(tmp) / "dest.txt"
dest.write_text("world")
with patch(
"media_shrinker.os.utime", side_effect=OSError("read-only fs")
):
with patch(
"media_shrinker._copy_macos_creation_time"
) as mock_creation:
with patch(
"media_shrinker._get_setfile_path", return_value="/bin/echo"
):
# Must not raise despite os.utime failing.
preserve_file_attributes(src, dest)
# Best-effort continues to the creation-time step after utime fails.
mock_creation.assert_called_once()
Loading