Compare commits

..
4 Commits
Author SHA1 Message Date
Fuxino 8d0fd46f44 Update PKGBUILD 2026-10-25 12:42:54 +01:00
Fuxino b65955f9f6 Bump version to 4.6.0 2026-10-25 12:42:22 +01:00
Fuxino 6d54019a18 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.
2026-10-25 12:36:00 +01:00
Fuxino 7547db1ea8 Update PKGBUILD 2026-10-21 22:50:33 +02:00
9 changed files with 310 additions and 9 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
pkgname=simple_backup pkgname=simple_backup
pkgdesc='Simple backup script that uses rsync to copy files' pkgdesc='Simple backup script that uses rsync to copy files'
pkgver=4.4.1 pkgver=4.6.0
pkgrel=1 pkgrel=1
url="https://git.shouldnt.work/fuxino/$pkgname" url="https://git.shouldnt.work/fuxino/$pkgname"
arch=('any') arch=('any')
@@ -24,7 +24,7 @@ checkdepends=('python-pytest'
conflicts=('simple_backup-git') conflicts=('simple_backup-git')
source=("git+$url?signed#tag=$pkgver") source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514') validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('cc5b378ac489f7cf3e3a38855dfd530a79f0651a1891ea83355b952ed3a20aee') sha256sums=('049c900b2d778434baa579409ccbb2b66ddb0968cf1dd23b3de0eda65ef3d2c6')
build() build()
{ {
+11
View File
@@ -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. 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. 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 ## Remote backup
> **Warning** > **Warning**
> This feature is experimental > This feature is experimental
+16 -1
View File
@@ -1,4 +1,4 @@
.TH SIMPLE_BACKUP 1 2026-10-21 SIMPLE_BACKUP 4.5.0 .TH SIMPLE_BACKUP 1 2026-10-25 SIMPLE_BACKUP 4.6.0
.SH NAME .SH NAME
simple_backup \- Backup files and folders using rsync simple_backup \- Backup files and folders using rsync
.SH SYNOPSIS .SH SYNOPSIS
@@ -145,6 +145,11 @@ needs to be allowed to run without password in the same way.
.TP .TP
.B \-\-numeric\-ids .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. 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 .SH CONFIGURATION
An example configuration file is provided at \(aq/usr/share/doc/simple_backup/simple_backup.conf\(aq. 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. Copy it to the default location ($HOME/.config/simple_backup) and edit it as needed.
@@ -216,6 +221,16 @@ SSH connection failed.
.TP .TP
.B 6 .B 6
Bad configuration file or options (for example, only one of SSH host and SSH user specified). 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 .SH SEE ALSO
.BR rsync (1) .BR rsync (1)
.SH AUTHORS .SH AUTHORS
+1 -1
View File
@@ -1,3 +1,3 @@
"""Init.""" """Init."""
__version__ = '4.5.0' __version__ = '4.6.0'
+114 -1
View File
@@ -315,6 +315,12 @@ class Backup:
if self._remote: if self._remote:
self._server = f'{self.ssh_user}@{self.ssh_host}:' 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: def remove_old_backups(self) -> None:
"""Remove old backups if there are more than indicated by 'keep'""" """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') help='Run rsync on remote server with sudo if allowed')
parser.add_argument('--numeric-ids', action='store_true', parser.add_argument('--numeric-ids', action='store_true',
help='Use rsync \'--numeric-ids\' option (don\'t map uid/gid values by name)') 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() args = parser.parse_args()
@@ -1077,6 +1085,103 @@ def _notify(text: str) -> None:
os.seteuid(euid) 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: def simple_backup() -> int:
"""Main""" """Main"""
@@ -1084,6 +1189,9 @@ def simple_backup() -> int:
user: str | None = args.user or _detect_user() 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: if not args.user and os.geteuid() != 0:
homedir = os.getenv('HOME', '') homedir = os.getenv('HOME', '')
elif user is not None: elif user is not None:
@@ -1167,8 +1275,13 @@ def simple_backup() -> int:
try: try:
return_code = backup.check_params(homedir) return_code = backup.check_params(homedir)
if return_code != 0:
return return_code
return_code = backup.run()
if return_code == 0: if return_code == 0:
return backup.run() _write_state(user, backup.destination)
return return_code return return_code
finally: finally:
+4 -2
View File
@@ -3,6 +3,7 @@
import sys import sys
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
import pytest import pytest
@@ -10,13 +11,14 @@ from simple_backup import simple_backup as sb
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def no_side_effects(monkeypatch: pytest.MonkeyPatch) -> None: def no_side_effects(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Keep tests out of the systemd journal and the desktop notifications""" """Keep tests out of the systemd journal, the desktop notifications and the user's state directory"""
if hasattr(sb, 'j_handler'): if hasattr(sb, 'j_handler'):
sb.logger.removeHandler(sb.j_handler) sb.logger.removeHandler(sb.j_handler)
monkeypatch.setattr(sb, '_notify', lambda text: None) monkeypatch.setattr(sb, '_notify', lambda text: None)
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'state'))
@pytest.fixture @pytest.fixture
+20 -1
View File
@@ -26,6 +26,8 @@ class FakeBackup:
instances: list['FakeBackup'] = [] instances: list['FakeBackup'] = []
check_code = 0 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, def __init__(self, inputs: Any, output: Any, exclude: Any, keep: Any, options: str,
ssh_host: str | None = None, ssh_user: str | None = None, ssh_host: str | None = None, ssh_user: str | None = None,
@@ -56,7 +58,7 @@ class FakeBackup:
def run(self) -> int: def run(self) -> int:
self.ran = True self.ran = True
return 0 return FakeBackup.run_code
def close(self) -> None: def close(self) -> None:
self.closed = True self.closed = True
@@ -66,6 +68,7 @@ class FakeBackup:
def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None: def fake_backup(monkeypatch: pytest.MonkeyPatch) -> None:
FakeBackup.instances = [] FakeBackup.instances = []
FakeBackup.check_code = 0 FakeBackup.check_code = 0
FakeBackup.run_code = 0
monkeypatch.setattr(sb, 'Backup', FakeBackup) 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""" """Run as root, with no sudo/doas variables set"""
monkeypatch.setattr(os, 'geteuid', lambda: 0) 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']: for var in ['SUDO_USER', 'SUDO_UID', 'DOAS_USER']:
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
@@ -270,6 +275,20 @@ ssh_keyfile=/keys/id
assert not b.ran assert not b.ran
assert b.closed 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: class TestUser:
@pytest.mark.usefixtures('as_user') @pytest.mark.usefixtures('as_user')
+5 -1
View File
@@ -201,7 +201,11 @@ class TestRsync:
for _ in range(2): for _ in range(2):
assert run_cli('-c', config_file, '-i', str(src), '-o', str(out), '-k', '0') == 0 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): class ChunkStream(io.BufferedIOBase):
+137
View File
@@ -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