From 6d54019a183a8dd8f0bce4659889a8a34267bf15 Mon Sep 17 00:00:00 2001 From: Fuxino Date: Sun, 25 Oct 2026 12:36:00 +0100 Subject: [PATCH] Save the last successful backup and add --last-backup Write the completion time and backup directory to $XDG_STATE_HOME/simple_backup/last_backup after each successful backup (as the target user when run as root), for status bar reminders. --last-backup prints it. --- README.md | 11 +++ man/simple_backup.1 | 15 ++++ simple_backup/simple_backup.py | 115 ++++++++++++++++++++++++++- tests/conftest.py | 6 +- tests/test_cli.py | 21 ++++- tests/test_local.py | 6 +- tests/test_state.py | 137 +++++++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 tests/test_state.py diff --git a/README.md b/README.md index 72c3465..1c5ac85 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ The number of old backups to keep can be set with the --keep (or -k) argument (o Old backups are only removed if rsync completes successfully, so that they are not deleted when the new backup may be incomplete. The only rsync error that is ignored is return code 24 (source files vanished during the transfer), which is common when backing up a live system. +## Last backup +After each successful backup, simple_backup saves its time and directory to `~/.local/state/simple_backup/last_backup` (or `$XDG_STATE_HOME/simple_backup/last_backup`). +When run with sudo or doas, the file goes to the home directory of the user who ran the command. +Print it with: + +```bash +simple_backup --last-backup +``` + +The file has two lines: the completion time in ISO 8601 format (e.g. `2026-10-25T14:03:12+02:00`) and the backup directory, so scripts (e.g. a status bar indicator) can easily read it to remind you when a backup is overdue. + ## Remote backup > **Warning** > This feature is experimental diff --git a/man/simple_backup.1 b/man/simple_backup.1 index 604d73a..8a4ebe1 100644 --- a/man/simple_backup.1 +++ b/man/simple_backup.1 @@ -145,6 +145,11 @@ needs to be allowed to run without password in the same way. .TP .B \-\-numeric\-ids Use rsync \-\-numeric\-ids option. This causes rsync to use numeric uid/gid instead of trying to map uid/gid names from the local machine to the server. +.TP +.B \-\-last\-backup +Print the date and directory of the last successful backup, then exit. The exit status is 0, or 1 +if no backup has been recorded yet or the state file can\(aqt be read (see +.BR FILES ). .SH CONFIGURATION An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq. Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed. @@ -216,6 +221,16 @@ SSH connection failed. .TP .B 6 Bad configuration file or options (for example, only one of SSH host and SSH user specified). +.SH FILES +.TP +.I $XDG_STATE_HOME/simple_backup/last_backup +Written after each successful backup (local or remote), replacing the previous one. The first line +is the completion time in ISO 8601 format with the UTC offset (for example +\(aq2026\-10\-25T14:03:12+02:00\(aq), the second line the backup directory (prefixed with +\(aquser@host:\(aq for remote backups). The default location is +\fI~/.local/state/simple_backup/last_backup\fR. When running as root with sudo, doas or \-\-user, +the file is written to that user\(aqs \fI~/.local/state\fR and owned by them. Useful for scripts +that remind you when a backup is overdue. .SH SEE ALSO .BR rsync (1) .SH AUTHORS diff --git a/simple_backup/simple_backup.py b/simple_backup/simple_backup.py index db00aac..1fc001e 100755 --- a/simple_backup/simple_backup.py +++ b/simple_backup/simple_backup.py @@ -315,6 +315,12 @@ class Backup: if self._remote: self._server = f'{self.ssh_user}@{self.ssh_host}:' + @property + def destination(self) -> str: + """The current backup directory, prefixed with 'user@host:' for remote backups""" + + return f'{self._server}{self._output_dir}' + def remove_old_backups(self) -> None: """Remove old backups if there are more than indicated by 'keep'""" @@ -902,6 +908,8 @@ def _parse_arguments() -> argparse.Namespace: help='Run rsync on remote server with sudo if allowed') parser.add_argument('--numeric-ids', action='store_true', help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)') + parser.add_argument('--last-backup', action='store_true', + help='Show when the last successful backup was made and exit') args = parser.parse_args() @@ -1077,6 +1085,103 @@ def _notify(text: str) -> None: os.seteuid(euid) +def _state_owner(user: str | None) -> pwd.struct_passwd | None: + """The user whose state file is used when running as root on their behalf (sudo, doas, -u)""" + + if os.geteuid() != 0 or user is None: + return None + + try: + owner = pwd.getpwnam(user) + except KeyError: + return None + + return owner if owner.pw_uid != 0 else None + + +def _state_file(owner: pwd.struct_passwd | None) -> str: + if owner is not None: + # XDG_STATE_HOME belongs to root here + base = os.path.join(owner.pw_dir, '.local', 'state') + else: + base = os.getenv('XDG_STATE_HOME', '') + + # The XDG spec says to ignore relative paths + if not os.path.isabs(base): + base = os.path.expanduser('~/.local/state') + + return os.path.join(base, 'simple_backup', 'last_backup') + + +def _write_state(user: str | None, destination: str) -> None: + """Record the time and directory of the last successful backup (two lines)""" + + owner = _state_owner(user) + path = _state_file(owner) + tmp = '' + euid, egid = os.geteuid(), os.getegid() + + # Create the file (and any missing directories) as the user, not root + if owner is not None: + os.setegid(owner.pw_gid) + os.seteuid(owner.pw_uid) + + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + + # Replace the file in one step, so readers (e.g. status bar scripts) never see it half written + fd, tmp = mkstemp(prefix='.last_backup', dir=os.path.dirname(path), text=True) + + with os.fdopen(fd, 'w', encoding='utf-8') as fp: + fp.write(f'{datetime.now().astimezone().isoformat(timespec="seconds")}\n{destination}\n') + + os.replace(tmp, path) + except OSError as e: + logger.warning('Cannot save the time of the last backup to %s: %s', path, e) + + if tmp != '': + try: + os.remove(tmp) + except OSError: + pass + finally: + if owner is not None: + os.seteuid(euid) + os.setegid(egid) + + +def _show_last_backup(user: str | None) -> int: + path = _state_file(_state_owner(user)) + + try: + with open(path, encoding='utf-8') as fp: + time, destination = fp.read().splitlines()[:2] + + completed = datetime.fromisoformat(time).astimezone() + except FileNotFoundError: + print('No backup recorded yet') + + return 1 + except (OSError, ValueError) as e: + print(f'Cannot read {path}: {e}', file=sys.stderr) + + return 1 + + days = (datetime.now().astimezone().date() - completed.date()).days + + if days == 0: + age = 'today' + elif days == 1: + age = 'yesterday' + else: + age = f'{days} days ago' + + print(f'Last backup: {completed:%Y-%m-%d %H:%M} ({age})') + print(f'Directory: {destination}') + + return 0 + + def simple_backup() -> int: """Main""" @@ -1084,6 +1189,9 @@ def simple_backup() -> int: user: str | None = args.user or _detect_user() + if args.last_backup: + return _show_last_backup(user) + if not args.user and os.geteuid() != 0: homedir = os.getenv('HOME', '') elif user is not None: @@ -1167,8 +1275,13 @@ def simple_backup() -> int: try: return_code = backup.check_params(homedir) + if return_code != 0: + return return_code + + return_code = backup.run() + if return_code == 0: - return backup.run() + _write_state(user, backup.destination) return return_code finally: diff --git a/tests/conftest.py b/tests/conftest.py index 93ebb83..3ac3ed0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import sys from collections.abc import Callable, Iterator from datetime import datetime, timedelta +from pathlib import Path import pytest @@ -10,13 +11,14 @@ from simple_backup import simple_backup as sb @pytest.fixture(autouse=True) -def no_side_effects(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep tests out of the systemd journal and the desktop notifications""" +def no_side_effects(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Keep tests out of the systemd journal, the desktop notifications and the user's state directory""" if hasattr(sb, 'j_handler'): sb.logger.removeHandler(sb.j_handler) monkeypatch.setattr(sb, '_notify', lambda text: None) + monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'state')) @pytest.fixture diff --git a/tests/test_cli.py b/tests/test_cli.py index c3c5847..7d1b249 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,6 +26,8 @@ class FakeBackup: instances: list['FakeBackup'] = [] check_code = 0 + run_code = 0 + destination = '/backups/simple_backup/2026-10-25_10-00-00' def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str, ssh_host: str | None = None, ssh_user: str | None = None, @@ -56,7 +58,7 @@ class FakeBackup: def run(self) -> int: self.ran = True - return 0 + return FakeBackup.run_code def close(self) -> None: self.closed = True @@ -66,6 +68,7 @@ class FakeBackup: def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None: FakeBackup.instances = [] FakeBackup.check_code = 0 + FakeBackup.run_code = 0 monkeypatch.setattr(sb, 'Backup', FakeBackup) @@ -83,6 +86,8 @@ def as_root(monkeypatch: pytest.MonkeyPatch) -> None: """Run as root, with no sudo/doas variables set""" monkeypatch.setattr(os, 'geteuid', lambda: 0) + # Writing the state file as root would call seteuid() (covered in test_state.py) + monkeypatch.setattr(sb, '_write_state', lambda user, destination: None) for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']: monkeypatch.delenv(var, raising=False) @@ -270,6 +275,20 @@ ssh_keyfile=/keys/id assert not b.ran assert b.closed + @pytest.mark.parametrize('check_code, run_code, saved', [(0, 0, True), (2, 0, False), (0, 4, False)]) + def test_state_saved_only_on_success(self, run_cli: RunCli, config: Callable[[str], str], tmp_path: Path, + check_code: int, run_code: int, saved: bool) -> None: + FakeBackup.check_code = check_code + FakeBackup.run_code = run_code + + assert run_cli('-c', config('backup_dir=/backups')) == (check_code or run_code) + + state = tmp_path / 'state' / 'simple_backup' / 'last_backup' + assert state.exists() is saved + + if saved: + assert state.read_text().splitlines()[1] == FakeBackup.destination + class TestUser: @pytest.mark.usefixtures('as_user') diff --git a/tests/test_local.py b/tests/test_local.py index 9b6c866..e4627e1 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -201,7 +201,11 @@ class TestRsync: for _ in range(2): assert run_cli('-c', config_file, '-i', str(src), '-o', str(out), '-k', '0') == 0 - assert backups(out) == ['2026-10-14_11-00-00'] + # The fake clock also advances when the state file is written + assert backups(out) == ['2026-10-14_12-00-00'] + state = (tmp_path / 'state' / 'simple_backup' / 'last_backup').read_text().splitlines() + assert state[0].startswith('2026-10-14T13:00:00') + assert state[1] == f'{out}/simple_backup/2026-10-14_12-00-00' class ChunkStream(io.BufferedIOBase): diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..767a306 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,137 @@ +"""Tests for the last backup state file and --last-backup""" + +import os +import pwd +from collections.abc import Callable +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from simple_backup import simple_backup as sb + +DESTINATION = '/backups/simple_backup/2026-10-25_10-00-00' + + +@pytest.fixture(autouse=True) +def as_user(monkeypatch: pytest.MonkeyPatch) -> None: + """Run as a non-root user (the tests may run as root in a chroot)""" + + monkeypatch.setattr(os, 'geteuid', lambda: 1000) + monkeypatch.setenv('USER', 'user') + + +@pytest.fixture +def state(tmp_path: Path) -> Path: + return tmp_path / 'state' / 'simple_backup' / 'last_backup' + + +def write_state(state: Path, text: str) -> None: + state.parent.mkdir(parents=True) + state.write_text(text) + + +def test_write(state: Path) -> None: + sb._write_state(None, DESTINATION) + + time, destination = state.read_text().splitlines() + assert abs(datetime.now().astimezone() - datetime.fromisoformat(time)) < timedelta(minutes=1) + assert destination == DESTINATION + # No temporary file left behind + assert os.listdir(state.parent) == ['last_backup'] + + +def test_overwrite(state: Path) -> None: + write_state(state, '2026-10-01T10:00:00+02:00\n/old\n') + + sb._write_state(None, DESTINATION) + + assert state.read_text().splitlines()[1] == DESTINATION + + +def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv('XDG_STATE_HOME', 'relative') + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + + sb._write_state(None, DESTINATION) + + assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists() + + +def test_write_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + # A file where the state directory should be + (tmp_path / 'not a dir').write_text('') + monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'not a dir')) + + sb._write_state(None, DESTINATION) + + assert 'Cannot save the time of the last backup' in caplog.text + + +def test_root_writes_as_user(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: list[tuple[str, int]] = [] + egid = os.getegid() + owner = pwd.struct_passwd(('user', 'x', 1000, 1001, '', str(tmp_path / 'home'), '/bin/sh')) + + monkeypatch.setattr(os, 'geteuid', lambda: 0) + monkeypatch.setattr(pwd, 'getpwnam', lambda name: owner) + monkeypatch.setattr(os, 'seteuid', lambda uid: calls.append(('euid', uid))) + monkeypatch.setattr(os, 'setegid', lambda gid: calls.append(('egid', gid))) + + sb._write_state('user', DESTINATION) + + # Root's XDG_STATE_HOME is not used + assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists() + assert calls == [('egid', 1001), ('euid', 1000), ('euid', 0), ('egid', egid)] + + +class TestStateOwner: + def test_non_root(self) -> None: + assert sb._state_owner('user') is None + + @pytest.mark.parametrize('name, expected', [('user', 'user'), ('root', None), ('unknown', None), (None, None)]) + def test_root(self, monkeypatch: pytest.MonkeyPatch, name: str | None, expected: str | None) -> None: + users = {'user': pwd.struct_passwd(('user', 'x', 1000, 1000, '', '/home/user', '/bin/sh')), + 'root': pwd.struct_passwd(('root', 'x', 0, 0, '', '/root', '/bin/sh'))} + + def getpwnam(name: str) -> pwd.struct_passwd: + if name not in users: + raise KeyError(name) + + return users[name] + + monkeypatch.setattr(os, 'geteuid', lambda: 0) + monkeypatch.setattr(pwd, 'getpwnam', getpwnam) + + owner = sb._state_owner(name) + assert (owner.pw_name if owner is not None else None) == expected + + +class TestShow: + @pytest.mark.parametrize('days, age', [(0, 'today'), (1, 'yesterday'), (3, '3 days ago')]) + def test_age(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str], + days: int, age: str) -> None: + completed = (datetime.now() - timedelta(days=days)).replace(hour=12, minute=30).astimezone() + write_state(state, f'{completed.isoformat(timespec="seconds")}\n{DESTINATION}\n') + + assert run_cli('--last-backup') == 0 + assert capsys.readouterr().out == \ + f'Last backup: {completed:%Y-%m-%d} 12:30 ({age})\nDirectory: {DESTINATION}\n' + + def test_after_write(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None: + sb._write_state(None, DESTINATION) + + assert run_cli('--last-backup') == 0 + assert '(today)' in capsys.readouterr().out + + def test_no_backup(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None: + assert run_cli('--last-backup') == 1 + assert capsys.readouterr().out == 'No backup recorded yet\n' + + @pytest.mark.parametrize('text', ['', '2026-10-25T10:00:00+02:00\n', 'yesterday\n/backups\n']) + def test_bad_file(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str], + text: str) -> None: + write_state(state, text) + + assert run_cli('--last-backup') == 1 + assert 'Cannot read' in capsys.readouterr().err