diff --git a/scripts/darktable_common.sh b/scripts/darktable_common.sh index 6b7cedf..8d20a06 100644 --- a/scripts/darktable_common.sh +++ b/scripts/darktable_common.sh @@ -59,6 +59,8 @@ validate_config() { validate_path SERVER_DB_DIR validate_path SERVER_PHOTO_DIR + validate_path LOCAL_DARKTABLE_DB_DIR + validate_path LOCAL_PHOTO_DIR # DARKTABLE_BIN: basename muss 'darktable' sein if [[ "$(basename "$DARKTABLE_BIN")" != "darktable" ]]; then @@ -127,6 +129,18 @@ gelöscht:^\*deleting EOF } +cleanup_old_backups() { + local backup_dir="$1" + [ -d "$backup_dir" ] || return 0 + local count + count=$(find "$backup_dir" -type f -mtime +730 | wc -l) + if [ "$count" -gt 0 ]; then + log "Backup-Bereinigung: $count Datei(en) älter als 2 Jahre in $backup_dir" + find "$backup_dir" -type f -mtime +730 -delete + find "$backup_dir" -type d -empty -delete 2>/dev/null || true + fi +} + confirm_dry_run() { [ "${DRY_RUN_SKIP_CONFIRM:-0}" = "1" ] && return 0 ask_user "Darktable Sync – Trockenlauf" \ diff --git a/scripts/darktable_sync.sh b/scripts/darktable_sync.sh index df8459e..4589369 100755 --- a/scripts/darktable_sync.sh +++ b/scripts/darktable_sync.sh @@ -180,6 +180,13 @@ TMPFILES+=("$DOWNLOAD_LOG_DB") DOWNLOAD_LOG_PHOTOS=$(mktemp) TMPFILES+=("$DOWNLOAD_LOG_PHOTOS") +BACKUP_PHOTO_DIR="${LOCAL_PHOTO_DIR}-bak" +BACKUP_DB_DIR="${LOCAL_DARKTABLE_DB_DIR}-bak" + +if [ "$DRY_RUN" = false ]; then + mkdir -p "$BACKUP_PHOTO_DIR" "$BACKUP_DB_DIR" +fi + RSYNC_DRY_FLAG=() [ "$DRY_RUN" = true ] && RSYNC_DRY_FLAG=(--dry-run) [ "$DRY_RUN" = true ] && DRY_SUFFIX=" (Trockenlauf)" || DRY_SUFFIX="" @@ -191,7 +198,12 @@ if [ "$UPLOAD_ALLOWED" = true ]; then log_step "Upload: Datenbank${DRY_SUFFIX}" log " Quelle: $LOCAL_DARKTABLE_DB_DIR/" log " Ziel: $SERVER_USER@$SERVER_IP:$SERVER_DB_DIR/" - if ! rsync -uavh --itemize-changes \ + if [ -z "$(ls -A "$LOCAL_DARKTABLE_DB_DIR" 2>/dev/null)" ]; then + log_error "Upload abgebrochen: Quellverzeichnis leer ($LOCAL_DARKTABLE_DB_DIR). Falscher Mount?" + touch "$CONFIG_DIR/sync_pending" + exit 1 + fi + if ! rsync -uavh --itemize-changes --delete \ "${RSYNC_DRY_FLAG[@]}" \ --exclude '*.lock' \ --exclude 'darktable_version' \ @@ -208,7 +220,12 @@ if [ "$UPLOAD_ALLOWED" = true ]; then log_step "Upload: Fotos${DRY_SUFFIX}" log " Quelle: $LOCAL_PHOTO_DIR/" log " Ziel: $SERVER_USER@$SERVER_IP:$SERVER_PHOTO_DIR/" - if ! rsync -uavh --itemize-changes \ + if [ -z "$(ls -A "$LOCAL_PHOTO_DIR" 2>/dev/null)" ]; then + log_error "Upload abgebrochen: Quellverzeichnis leer ($LOCAL_PHOTO_DIR). Falscher Mount?" + touch "$CONFIG_DIR/sync_pending" + exit 1 + fi + if ! rsync -uavh --itemize-changes --delete \ "${RSYNC_DRY_FLAG[@]}" \ --exclude '*.lock' \ -e "ssh -p $SERVER_SSH_PORT" \ @@ -227,7 +244,9 @@ fi log_step "Download: Datenbank${DRY_SUFFIX}" log " Quelle: $SERVER_USER@$SERVER_IP:$SERVER_DB_DIR/" log " Ziel: $LOCAL_DARKTABLE_DB_DIR/" -if ! rsync -uavh --itemize-changes \ +log " Backup: $BACKUP_DB_DIR/" +if ! rsync -uavh --itemize-changes --delete \ + --backup --backup-dir="$BACKUP_DB_DIR" \ "${RSYNC_DRY_FLAG[@]}" \ --exclude '*.lock' \ -e "ssh -p $SERVER_SSH_PORT" \ @@ -243,7 +262,9 @@ log "Datenbank-Download abgeschlossen: $RECEIVED_DB Datei(en) empfangen." log_step "Download: Fotos${DRY_SUFFIX}" log " Quelle: $SERVER_USER@$SERVER_IP:$SERVER_PHOTO_DIR/" log " Ziel: $LOCAL_PHOTO_DIR/" -if ! rsync -uavh --itemize-changes \ +log " Backup: $BACKUP_PHOTO_DIR/" +if ! rsync -uavh --itemize-changes --delete \ + --backup --backup-dir="$BACKUP_PHOTO_DIR" \ "${RSYNC_DRY_FLAG[@]}" \ --exclude '*.lock' \ -e "ssh -p $SERVER_SSH_PORT" \ @@ -266,6 +287,10 @@ if [ "$DRY_RUN" = false ]; then rm -f "$CONFIG_DIR/sync_pending" log "sync_pending entfernt." + + log_step "Backup-Bereinigung (älter als 2 Jahre)" + cleanup_old_backups "$BACKUP_PHOTO_DIR" + cleanup_old_backups "$BACKUP_DB_DIR" fi TOTAL_SENT=$((SENT_DB + SENT_PHOTOS)) @@ -283,8 +308,8 @@ if [ "$DRY_RUN" = true ]; then DN_DEL=$( echo "$download_log" | grep -cE '^\*deleting' || echo 0) log_step "Trockenlauf-Ergebnis" - log " Upload: $UP_NEW neu | $UP_UPD aktualisiert | $UP_DEL gelöscht" - log " Download: $DN_NEW neu | $DN_UPD aktualisiert | $DN_DEL gelöscht" + log " Upload: $UP_NEW neu | $UP_UPD aktualisiert | $UP_DEL gelöscht (Server)" + log " Download: $DN_NEW neu | $DN_UPD aktualisiert | $DN_DEL gelöscht → Backup: $BACKUP_PHOTO_DIR" if [ "$((TOTAL_SENT + TOTAL_RECEIVED))" -gt 0 ]; then if ask_user "Details" "Details der zu übertragenden Dateien anzeigen?"; then diff --git a/tests/darktable_common.bats b/tests/darktable_common.bats index eb2e3da..c621018 100644 --- a/tests/darktable_common.bats +++ b/tests/darktable_common.bats @@ -75,3 +75,40 @@ COMMON_SCRIPT="$BATS_TEST_DIRNAME/../scripts/darktable_common.sh" rm -f "$TMP_SCRIPT" [ "$status" -eq 1 ] } + +# --- cleanup_old_backups --- + +@test "cleanup_old_backups: nicht existierendes Verzeichnis gibt kein Fehler" { + run bash -c "source '$COMMON_SCRIPT'; cleanup_old_backups '/tmp/nonexistent_bak_$RANDOM'" + [ "$status" -eq 0 ] +} + +@test "cleanup_old_backups: Datei juenger als 730 Tage bleibt erhalten" { + BACKUP="$BATS_TMPDIR/backup_test" + mkdir -p "$BACKUP" + touch "$BACKUP/recent.jpg" + run bash -c "source '$COMMON_SCRIPT'; cleanup_old_backups '$BACKUP'" + [ "$status" -eq 0 ] + [ -f "$BACKUP/recent.jpg" ] + rm -rf "$BACKUP" +} + +@test "cleanup_old_backups: Datei aelter als 730 Tage wird geloescht" { + BACKUP="$BATS_TMPDIR/backup_old" + mkdir -p "$BACKUP" + touch -d "3 years ago" "$BACKUP/old.jpg" + run bash -c "source '$COMMON_SCRIPT'; cleanup_old_backups '$BACKUP'" + [ "$status" -eq 0 ] + [ ! -f "$BACKUP/old.jpg" ] + rm -rf "$BACKUP" +} + +@test "cleanup_old_backups: leere Unterverzeichnisse werden entfernt" { + BACKUP="$BATS_TMPDIR/backup_empty" + mkdir -p "$BACKUP/subdir" + touch -d "3 years ago" "$BACKUP/subdir/old.jpg" + run bash -c "source '$COMMON_SCRIPT'; cleanup_old_backups '$BACKUP'" + [ "$status" -eq 0 ] + [ ! -d "$BACKUP/subdir" ] + rm -rf "$BACKUP" +} diff --git a/tests/darktable_sync.bats b/tests/darktable_sync.bats index 532b192..315d5b0 100644 --- a/tests/darktable_sync.bats +++ b/tests/darktable_sync.bats @@ -11,6 +11,7 @@ setup() { touch "$HOME/.config/darktable/data.db" rm -f "$HOME/.config/darktable/"*.bak mkdir -p "$HOME/Pictures" + touch "$HOME/Pictures/test.jpg" export DISPLAY=:99 } @@ -109,3 +110,51 @@ setup() { [ "$status" -eq 0 ] [[ "$output" == *"neu"* ]] } + +# --- Tests: --delete und Backup-Verhalten --- + +@test "Upload-rsync enthaelt --delete Flag" { + ARGS_FILE=$(mktemp) + run_with_stubs env SSH_STUB_FAIL=0 RSYNC_STUB_ARGS_FILE="$ARGS_FILE" bash "$SYNC_SCRIPT" --execute + [ "$status" -eq 0 ] + grep -q -- "--delete" "$ARGS_FILE" + rm -f "$ARGS_FILE" +} + +@test "Download-rsync enthaelt --backup und --backup-dir" { + ARGS_FILE=$(mktemp) + run_with_stubs env SSH_STUB_FAIL=0 RSYNC_STUB_ARGS_FILE="$ARGS_FILE" bash "$SYNC_SCRIPT" --execute + [ "$status" -eq 0 ] + grep -q -- "--backup" "$ARGS_FILE" + grep -q -- "--backup-dir=" "$ARGS_FILE" + rm -f "$ARGS_FILE" +} + +@test "Backup-Verzeichnisse werden bei echtem Sync angelegt" { + run_with_stubs env SSH_STUB_FAIL=0 bash "$SYNC_SCRIPT" --execute + [ "$status" -eq 0 ] + [ -d "$HOME/Pictures-bak" ] + [ -d "$HOME/.config/darktable-bak" ] +} + +@test "Trockenlauf legt keine Backup-Verzeichnisse an" { + run_with_stubs env SSH_STUB_FAIL=0 DRY_RUN_SKIP_CONFIRM=1 bash "$SYNC_SCRIPT" + [ "$status" -eq 0 ] + [ ! -d "$HOME/Pictures-bak" ] + [ ! -d "$HOME/.config/darktable-bak" ] +} + +@test "Upload bricht ab wenn Foto-Quellverzeichnis leer ist" { + rm -f "$HOME/Pictures/"* + run_with_stubs env SSH_STUB_FAIL=0 bash "$SYNC_SCRIPT" --execute + [ "$status" -eq 1 ] + [[ "$output" == *"leer"* ]] + [ -f "$CONFIG_DIR/sync_pending" ] +} + +@test "Trockenlauf-Ergebnis zeigt Backup-Hinweis bei Loeschungen" { + run_with_stubs env SSH_STUB_FAIL=0 DRY_RUN_SKIP_CONFIRM=1 \ + RSYNC_STUB_DRY_LINES="*deleting foto.jpg" bash "$SYNC_SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"Backup"* ]] +} diff --git a/tests/helpers/setup.bash b/tests/helpers/setup.bash index a7ecbc3..97c5f61 100644 --- a/tests/helpers/setup.bash +++ b/tests/helpers/setup.bash @@ -23,9 +23,10 @@ SYNC_BIN=$HOME/.local/bin/darktable_sync.sh EOF } -# Raeumt nach jedem Test auf (verhindert Lock-Leakage zwischen Tests) +# Raeumt nach jedem Test auf (verhindert Lock- und Backup-Dir-Leakage zwischen Tests) teardown() { rm -rf "$CONFIG_DIR/sync.lock" + rm -rf "$HOME/Pictures-bak" "$HOME/.config/darktable-bak" } # Fuehrt ein Script mit dem Stubs-Verzeichnis vorne im PATH aus diff --git a/tests/security.bats b/tests/security.bats index af610a4..dfdb43c 100644 --- a/tests/security.bats +++ b/tests/security.bats @@ -13,6 +13,7 @@ setup() { touch "$HOME/.config/darktable/library.db" touch "$HOME/.config/darktable/data.db" mkdir -p "$HOME/Pictures" + touch "$HOME/Pictures/test.jpg" export DISPLAY=:99 } @@ -241,3 +242,29 @@ EOF [ "$status" -eq 0 ] [ ! -f /tmp/evil_stub ] } + +# --- Backup-Pfad Security --- + +@test "security: LOCAL_PHOTO_DIR mit Path-Traversal wird geblockt" { + create_valid_env + echo "LOCAL_PHOTO_DIR=/home/user/../../../etc/photos" >> "$CONFIG_DIR/.env" + run bash -c "source '$COMMON_SCRIPT'; load_config; validate_config" + [ "$status" -eq 1 ] + [[ "$output" == *"LOCAL_PHOTO_DIR"* ]] +} + +@test "security: LOCAL_DARKTABLE_DB_DIR mit Path-Traversal wird geblockt" { + create_valid_env + echo "LOCAL_DARKTABLE_DB_DIR=/home/user/../../../etc/dt" >> "$CONFIG_DIR/.env" + run bash -c "source '$COMMON_SCRIPT'; load_config; validate_config" + [ "$status" -eq 1 ] + [[ "$output" == *"LOCAL_DARKTABLE_DB_DIR"* ]] +} + +@test "security: LOCAL_PHOTO_DIR mit Single-Quote wird geblockt" { + create_valid_env + printf 'LOCAL_PHOTO_DIR="/home/user/pics'"'"'injection"\n' >> "$CONFIG_DIR/.env" + run bash -c "source '$COMMON_SCRIPT'; load_config; validate_config" + [ "$status" -eq 1 ] + [[ "$output" == *"LOCAL_PHOTO_DIR"* ]] +} diff --git a/tests/stubs/rsync b/tests/stubs/rsync index 8cd3d1d..a3b169d 100755 --- a/tests/stubs/rsync +++ b/tests/stubs/rsync @@ -1,7 +1,11 @@ #!/bin/bash # rsync-Stub: Verhalten per Umgebungsvariable steuerbar -# RSYNC_STUB_FAIL=1 → schlaegt fehl -# RSYNC_STUB_DRY_LINES → Ausgabe bei --dry-run (Zeilenumbrüche als \n) +# RSYNC_STUB_FAIL=1 → schlaegt fehl +# RSYNC_STUB_DRY_LINES → Ausgabe bei --dry-run (Zeilenumbrüche als \n) +# RSYNC_STUB_ARGS_FILE → Pfad zu Datei, in die alle Argumente geschrieben werden +if [ -n "${RSYNC_STUB_ARGS_FILE:-}" ]; then + echo "$*" >> "$RSYNC_STUB_ARGS_FILE" +fi if [ "${RSYNC_STUB_FAIL:-0}" = "1" ]; then exit 1 fi