git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

scripts/backup-system.sh (3.4K)

 1 #!/bin/sh
 2 # Full-system backup, run as root via pkexec from the settings app's Storage
 3 # page. Installed to ~/.local/bin by `ccebuild install` (it ships every crate's
 4 # scripts/ dir); it lived unversioned in ~/.local/share/<app>/helpers/ until
 5 # 2026-08-23, where three renames of this app had left the caller looking for
 6 # it under a name it no longer had.
 7 #
 8 # The status file is passed in as $1 rather than recomputed here. It is the one
 9 # thing both sides must agree on, and they cannot agree by construction: the
10 # caller resolves it through XDG as the user, while this runs as root under
11 # pkexec, which scrubs the environment. Passing it makes the contract explicit
12 # instead of two hardcoded paths that silently drifted apart (they had).
13 #
14 # Everything user-specific comes from PKEXEC_UID, the uid pkexec records for
15 # whoever authenticated — never a hardcoded name or home.
16 
17 set -eu
18 
19 STATUS_FILE="${1:?usage: backup-system.sh <status-file>}"
20 
21 if [ -z "${PKEXEC_UID:-}" ]; then
22     echo "Error: not running under pkexec (no PKEXEC_UID)" >&2
23     exit 1
24 fi
25 RUN_USER=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
26 if [ -z "$RUN_USER" ]; then
27     echo "Error: cannot resolve uid $PKEXEC_UID to a user" >&2
28     exit 1
29 fi
30 
31 # Previous values are preserved on failure so a failed run reports its error
32 # without also blanking the last good backup's time and size.
33 PREV_TIME="Never"
34 PREV_SIZE="0 B"
35 read_previous() {
36     [ -f "$STATUS_FILE" ] || return 0
37     PREV_TIME=$(sed -n 's/^last_backup_time *= *//p' "$STATUS_FILE" | head -1)
38     PREV_SIZE=$(sed -n 's/^backup_size *= *//p' "$STATUS_FILE" | head -1)
39     [ -n "$PREV_TIME" ] || PREV_TIME="Never"
40     [ -n "$PREV_SIZE" ] || PREV_SIZE="0 B"
41 }
42 
43 # Written as root, so hand it back to the invoking user — the app reads it
44 # unprivileged on its next refresh.
45 write_status() {
46     mkdir -p "$(dirname "$STATUS_FILE")"
47     cat > "$STATUS_FILE" <<EOF
48 last_backup_time = $1
49 backup_size = $2
50 error_message = ${3:-}
51 EOF
52     chown "$PKEXEC_UID" "$STATUS_FILE" 2>/dev/null || true
53     chown "$PKEXEC_UID" "$(dirname "$STATUS_FILE")" 2>/dev/null || true
54 }
55 
56 fail() {
57     read_previous
58     write_status "$PREV_TIME" "$PREV_SIZE" "$1"
59     echo "Error: $1" >&2
60     exit 1
61 }
62 
63 # 1. Destination: /mnt/usb, else the first real mount under the user's media dir
64 DEST="/mnt/usb"
65 if ! mountpoint -q "$DEST"; then
66     MEDIA_DIR="/run/media/$RUN_USER"
67     DEST=""
68     if [ -d "$MEDIA_DIR" ]; then
69         for d in "$MEDIA_DIR"/*; do
70             if [ -d "$d" ] && mountpoint -q "$d"; then
71                 DEST="$d"
72                 break
73             fi
74         done
75     fi
76     [ -n "$DEST" ] || fail "No external drive mounted at /mnt/usb or $MEDIA_DIR/*"
77 fi
78 
79 # Archive name kept as-is deliberately: renaming it to match the app's current
80 # name would orphan any archive already sitting on the drive rather than
81 # overwriting it.
82 ARCHIVE_PATH="$DEST/clear-system-backup.tar.gz"
83 echo "Starting full system backup to $ARCHIVE_PATH..."
84 
85 # --one-file-system keeps tar out of virtual mounts and other drives; the
86 # destination is excluded so the archive cannot recurse into itself.
87 tar --one-file-system \
88     --exclude="/lost+found" \
89     --exclude="$DEST" \
90     -czf "$ARCHIVE_PATH" \
91     -C / . || fail "Backup archive creation failed"
92 
93 SIZE_STR=$(du -sh "$ARCHIVE_PATH" | awk '{print $1}')
94 DATE_STR=$(date "+%Y-%m-%d %H:%M:%S")
95 write_status "$DATE_STR" "$SIZE_STR" ""
96 echo "Backup completed successfully!"