Verify changing WebDAV hash metadata without rejecting unchanged files

This commit is contained in:
admin committed 2026-10-26 12:27:01 +01:00
1 parent 7e94915442
commit 8245571f15
4 files changed
+43 -5

No files matched your search

+2 -2
View File
@@ -47,7 +47,7 @@ Vollständige Kindprozessausgaben stehen im systemd-Journal; die zusätzlichen A
- Staging muss lokal sein, darf keine Symlinks/Mounts enthalten und darf sich nicht mit State, Repository oder `~/Nextcloud` überschneiden. Ein bereits gefülltes, fremdes Verzeichnis wird nicht übernommen. - Staging muss lokal sein, darf keine Symlinks/Mounts enthalten und darf sich nicht mit State, Repository oder `~/Nextcloud` überschneiden. Ein bereits gefülltes, fremdes Verzeichnis wird nicht übernommen.
- Hashvergleich, wenn jede Quelldatei MD5/SHA1 anbietet. Dateien ohne Serverhash werden vor dem Sync mit `rclone copy --ignore-times` erneut gelesen und anschließend zusätzlich per `rclone check --download` verglichen. Leere Dateien sind bereits durch Größe null eindeutig. Bei Zeilenumbrüchen in ungehashten Dateinamen erfolgt ein vollständiger Bytevergleich aller Dateien, weil rclones Dateiliste solche Namen nicht darstellen kann. Das kostet zusätzlich Übertragungszeit, vermeidet aber den unsicheren Rückfall auf reine Größenvergleiche. - Hashvergleich, wenn jede Quelldatei MD5/SHA1 anbietet. Dateien ohne Serverhash werden vor dem Sync mit `rclone copy --ignore-times` erneut gelesen und anschließend zusätzlich per `rclone check --download` verglichen. Leere Dateien sind bereits durch Größe null eindeutig. Bei Zeilenumbrüchen in ungehashten Dateinamen erfolgt ein vollständiger Bytevergleich aller Dateien, weil rclones Dateiliste solche Namen nicht darstellen kann. Das kostet zusätzlich Übertragungszeit, vermeidet aber den unsicheren Rückfall auf reine Größenvergleiche.
- Zusätzlich berechnet Python die lokalen SHA1-/MD5-Werte unabhängig von rclone und vergleicht jede verfügbare Cloud-Prüfsumme sowie die Dateigrößen. - Zusätzlich berechnet Python die lokalen SHA1-/MD5-Werte unabhängig von rclone und vergleicht jede verfügbare Cloud-Prüfsumme sowie die Dateigrößen.
- Inventur vor und nach Sync/Check muss exakt gleich sein, einschließlich Dateinamen, Größen, Zeitstempeln und vorhandenen Hashes. Änderungen während des Laufs führen zu einem späteren neuen Versuch. - Inventuren vor und nach Sync/Check müssen dieselben Dateinamen, Größen und Zeitstempel enthalten. Dateiveränderungen führen zu einem späteren neuen Versuch. Erscheinen oder verschwinden allein WebDAV-Prüfsummen, werden die betroffenen Dateien zusätzlich gegen die neu gemeldeten Hashes bzw. per Downloadvergleich geprüft; nur nach erfolgreichem Inhaltsvergleich darf die Sicherung weiterlaufen. Die Inventuren bleiben für die Diagnose privat gespeichert.
- Borg-Warnungen zählen als Fehler. Jeder neue Archivstand durchläuft `borg check --archives-only --verify-data --glob-archives <Archiv>` und einen Repository-Check. Ältere Optionen `FULL_CHECK_VERIFY_DATA`, `FULL_CHECK_INTERVAL_DAYS`, `REPOSITORY_CHECK_AFTER_BACKUP`, `SOURCE_DIR`, `ONE_FILE_SYSTEM` und `EXCLUDE_CACHES` beeinflussen den neuen Backup-Pfad nicht mehr: jedes neue Archiv wird geprüft und alle Cloud-Dateien werden erfasst. Aktive alte Exclude-Regeln führen zu einem erklärenden Abbruch, statt unbemerkt Dateien auszulassen. - Borg-Warnungen zählen als Fehler. Jeder neue Archivstand durchläuft `borg check --archives-only --verify-data --glob-archives <Archiv>` und einen Repository-Check. Ältere Optionen `FULL_CHECK_VERIFY_DATA`, `FULL_CHECK_INTERVAL_DAYS`, `REPOSITORY_CHECK_AFTER_BACKUP`, `SOURCE_DIR`, `ONE_FILE_SYSTEM` und `EXCLUDE_CACHES` beeinflussen den neuen Backup-Pfad nicht mehr: jedes neue Archiv wird geprüft und alle Cloud-Dateien werden erfasst. Aktive alte Exclude-Regeln führen zu einem erklärenden Abbruch, statt unbemerkt Dateien auszulassen.
- Erst nach dieser Prüfung wird der Erfolg gespeichert. Ein anschließender Wartungsfehler lässt das bestätigte Archiv gültig; der nächste Timerlauf versucht die Wartung erneut. `--keep-within 4w --keep-last 7` schützt zusätzlich die letzten sieben Archivstände. - Erst nach dieser Prüfung wird der Erfolg gespeichert. Ein anschließender Wartungsfehler lässt das bestätigte Archiv gültig; der nächste Timerlauf versucht die Wartung erneut. `--keep-within 4w --keep-last 7` schützt zusätzlich die letzten sieben Archivstände.
- Einzelne Kindprozesse sind auf sechs Stunden begrenzt; systemd begrenzt den Gesamtlauf auf zwölf Stunden und beendet die gesamte Prozessgruppe. Keine endlose Wiederholung innerhalb eines Backup-Prozesses. Das nächste 15-Minuten-Fenster versucht erneut. - Einzelne Kindprozesse sind auf sechs Stunden begrenzt; systemd begrenzt den Gesamtlauf auf zwölf Stunden und beendet die gesamte Prozessgruppe. Keine endlose Wiederholung innerhalb eines Backup-Prozesses. Das nächste 15-Minuten-Fenster versucht erneut.
@@ -106,7 +106,7 @@ Passphrase und exportierten Schlüssel unabhängig vom NAS und der Workstation s
`make check` prüft Bash-Syntax, Python, alle vier systemd-Units und automatisierte Ausfallszenarien. Integrationstests verwenden echtes rclone mit lokalem Test-Remote sowie echtes Borg in temporären Verzeichnissen: Archiv erstellen, Daten prüfen, extrahieren und Bytevergleich. Sie verändern weder echte Cloud noch produktives Repository. `make check` prüft Bash-Syntax, Python, alle vier systemd-Units und automatisierte Ausfallszenarien. Integrationstests verwenden echtes rclone mit lokalem Test-Remote sowie echtes Borg in temporären Verzeichnissen: Archiv erstellen, Daten prüfen, extrahieren und Bytevergleich. Sie verändern weder echte Cloud noch produktives Repository.
15 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt. 17 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt.
Der Installer aktualisiert Programme und Units, erhält private Konfiguration und Schlüssel und startet die Timer neu. Eine fehlende Passphrase bei vorhandenem Repository führt zum Abbruch. Die Deinstallation stoppt Backup/Timer und entfernt Programme/Units; Daten, Schlüssel, State und Archive bleiben erhalten. Der Installer aktualisiert Programme und Units, erhält private Konfiguration und Schlüssel und startet die Timer neu. Eine fehlende Passphrase bei vorhandenem Repository führt zum Abbruch. Die Deinstallation stoppt Backup/Timer und entfernt Programme/Units; Daten, Schlüssel, State und Archive bleiben erhalten.
+1 -1
View File
@@ -7,7 +7,7 @@ Die Übertragung geht ausschließlich **von Nextcloud zum NAS**. Dateien werden
## Schutz und Zeitplanung ## Schutz und Zeitplanung
- Alle 15 Minuten wird geprüft, ob eine Sicherung fällig ist; nach Erfolg frühestens nach 24 Stunden. Fehler werden beim nächsten Timerlauf erneut versucht, auch nach Neustart oder Aufwachen. - Alle 15 Minuten wird geprüft, ob eine Sicherung fällig ist; nach Erfolg frühestens nach 24 Stunden. Fehler werden beim nächsten Timerlauf erneut versucht, auch nach Neustart oder Aufwachen.
- Eigene direkte Cloud-Verbindung ohne FUSE-Verzeichnis-Cache. Vollständige Inventur vor und nach der Übertragung; Abbruch, wenn sich die Quelle währenddessen ändert. - Eigene direkte Cloud-Verbindung ohne FUSE-Verzeichnis-Cache. Vollständige Inventur vor und nach der Übertragung; Abbruch bei Dateiänderungen während des Laufs; wechselnde WebDAV-Prüfsummen werden zusätzlich inhaltlich geprüft.
- SHA1/MD5-Prüfung des Zwischenstands gegen Nextcloud. Dateien ohne Serverhash werden vollständig neu gelesen und per Downloadvergleich geprüft. - SHA1/MD5-Prüfung des Zwischenstands gegen Nextcloud. Dateien ohne Serverhash werden vollständig neu gelesen und per Downloadvergleich geprüft.
- Vor und nach der Cloud-Übertragung: Prüfung des tatsächlichen NFS-Mounts, des vorhandenen Repositorys und des freien Speicherplatzes. Keine automatische Repository-Neuanlage im Backup. - Vor und nach der Cloud-Übertragung: Prüfung des tatsächlichen NFS-Mounts, des vorhandenen Repositorys und des freien Speicherplatzes. Keine automatische Repository-Neuanlage im Backup.
- Schutz vor leerer Quelle, Rückgang von Dateizahl oder Datenmenge um mehr als 20 Prozent und mehr als 1.000 Löschungen im lokalen Zwischenstand. - Schutz vor leerer Quelle, Rückgang von Dateizahl oder Datenmenge um mehr als 20 Prozent und mehr als 1.000 Löschungen im lokalen Zwischenstand.
+24 -2
View File
@@ -145,6 +145,28 @@ class Runner:
raise BackupError(f'Independent staging hash mismatch: {name}') raise BackupError(f'Independent staging hash mismatch: {name}')
log('Independent local size/hash verification passed') log('Independent local size/hash verification passed')
def validate_final_inventory(self, before, after):
if before == after:
return
atomic_json(self.state / 'changed-inventory.json', after)
# WebDAV checksum properties can appear/disappear without a file changing.
# Changes to actual paths, sizes or modification times still require a new run.
metadata = lambda values: {name: (item['size'], item['mtime']) for name, item in values.items()}
if metadata(before) != metadata(after):
raise BackupError('Cloud changed during sync/verification (paths, sizes or modification times); retry with a stable inventory')
changed = {name: after[name] for name in before if before[name]['hashes'] != after[name]['hashes']}
self.verify_local_hashes(changed)
missing = [name for name, item in changed.items() if item['size'] and not any(
key.lower().replace('-', '') in ('sha1', 'md5') for key in item['hashes'])]
if missing:
flags = []
if not any('\n' in name or '\r' in name for name in missing):
file_list = self.state / 'changed-hash-files.txt'
file_list.write_text('\n'.join(sorted(missing)) + '\n')
flags = ['--files-from-raw', str(file_list)]
self.cloud('check', self.remote, str(self.stage), '--download', *flags)
log(f'Checksum metadata changed on {len(changed)} files; current contents independently verified')
@staticmethod @staticmethod
def totals(manifest): def totals(manifest):
return len(manifest), sum(x['size'] for x in manifest.values()) return len(manifest), sum(x['size'] for x in manifest.values())
@@ -273,6 +295,7 @@ class Runner:
baseline = self.baseline() baseline = self.baseline()
self.phase = 'cloud inventory' self.phase = 'cloud inventory'
before = self.manifest() before = self.manifest()
atomic_json(self.state / 'source-inventory.json', before)
count, size = self.guard_source(before, baseline) count, size = self.guard_source(before, baseline)
self.staging_preflight(before) self.staging_preflight(before)
self.phase = 'cloud sync' self.phase = 'cloud sync'
@@ -298,8 +321,7 @@ class Runner:
'--files-from-raw', str(file_list)) '--files-from-raw', str(file_list))
self.verify_local_hashes(before) self.verify_local_hashes(before)
after = self.manifest() after = self.manifest()
if before != after: self.validate_final_inventory(before, after)
raise BackupError('Cloud changed during sync/verification; retry with a stable inventory')
atomic_json(self.state / 'verified-inventory.json', after) atomic_json(self.state / 'verified-inventory.json', after)
self.nas() self.nas()
self.phase = 'borg create' self.phase = 'borg create'
+16
View File
@@ -159,6 +159,22 @@ class BackupTests(unittest.TestCase):
with self.assertRaisesRegex(m.BackupError, 'hash mismatch'): with self.assertRaisesRegex(m.BackupError, 'hash mismatch'):
self.runner.verify_local_hashes(manifest) self.runner.verify_local_hashes(manifest)
def test_disappearing_hash_requires_download_comparison(self):
before = {'file': {'size': 10, 'mtime': 'now', 'hashes': {'SHA-1': 'abc'}}}
after = {'file': {'size': 10, 'mtime': 'now', 'hashes': {}}}
with patch.object(self.runner, 'verify_local_hashes') as local, \
patch.object(self.runner, 'cloud') as cloud:
self.runner.validate_final_inventory(before, after)
local.assert_called_once_with(after)
self.assertIn('--download', cloud.call_args.args)
def test_new_hash_with_wrong_contents_is_rejected(self):
before = {'file': {'size': 10, 'mtime': 'now', 'hashes': {}}}
after = {'file': {'size': 10, 'mtime': 'now', 'hashes': {'SHA-1': 'abc'}}}
with patch.object(self.runner, 'verify_local_hashes', side_effect=m.BackupError('hash mismatch')):
with self.assertRaises(m.BackupError):
self.runner.validate_final_inventory(before, after)
def test_pending_maintenance_survives_interrupted_attempt_record(self): def test_pending_maintenance_survives_interrupted_attempt_record(self):
import time import time
self.runner.success = {'completed_at': time.time(), 'maintenance_pending': True} self.runner.success = {'completed_at': time.time(), 'maintenance_pending': True}