Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no guard against a collapsed config value.
This commit is contained in:
+52
-5
@@ -64,11 +64,45 @@
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# acquire_lock "wait" — wait if previous run still active
|
||||
# detect_hosts() — correct folder lists per host via MY_ID aliases
|
||||
# Empty array guards — warns and exits cleanly if no folders or patterns configured
|
||||
# Folder existence — skips missing folders with warning, continues others
|
||||
# platform_require_cmd — notify script validated before use
|
||||
# Root Enforcement
|
||||
# Media files are owned by container users; deleting them requires root.
|
||||
#
|
||||
# Profile Required
|
||||
# Exits with usage if no profile is given. There is no default profile — an
|
||||
# unspecified profile must never fall through to cleaning something.
|
||||
#
|
||||
# Lock Acquisition
|
||||
# acquire_lock "wait" — waits for a previous run to finish rather than
|
||||
# skipping, so a long anime pass does not cause the media pass to be dropped.
|
||||
#
|
||||
# Host Detection
|
||||
# detect_hosts() aliases HOST*_ANIME_CLEAN_FOLDERS / HOST*_MEDIA_CLEAN_FOLDERS
|
||||
# to the correct host's values.
|
||||
#
|
||||
# Empty Array Guards
|
||||
# Exits cleanly if the resolved folder list or pattern list is empty. An empty
|
||||
# pattern list would otherwise build a find with no -iname terms and match
|
||||
# every file in the tree.
|
||||
#
|
||||
# Clean Path Depth Guard
|
||||
# Every folder must be an absolute path at least three levels deep before it is
|
||||
# scanned. The patterns include *.sh, *.zip, *.rar and *.exe, so a truncated
|
||||
# entry like /mnt/user — which passes an existence check — would delete
|
||||
# matching files across every share on the array.
|
||||
#
|
||||
# Folder Existence
|
||||
# Missing folders are skipped with a warning; remaining folders still process.
|
||||
#
|
||||
# Explicit Pattern List
|
||||
# Only patterns named in ANIME_FILE_PATTERNS / MEDIA_FILE_PATTERNS are removed.
|
||||
# The script never infers intent from file size, age, or location.
|
||||
#
|
||||
# Count Before Delete
|
||||
# Matching files are counted first; a folder with zero matches short-circuits
|
||||
# before any rm is constructed.
|
||||
#
|
||||
# Dry Run Support
|
||||
# --dry-run lists every file that would be deleted and removes nothing.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
@@ -205,6 +239,19 @@ for FOLDER in "${CLEAN_FOLDERS[@]}"; do
|
||||
FOLDER_NAME=$(basename "$FOLDER")
|
||||
echo "━━━ $ICON_CLEAN $FOLDER_NAME ━━━"
|
||||
|
||||
# The pattern list includes *.sh, *.zip, *.rar and *.exe. A truncated entry such as
|
||||
# /mnt/user passes the -d check below and would sweep every share on the array, so
|
||||
# require an absolute path at least three levels deep before scanning anything.
|
||||
_depth="${FOLDER//[^\/]/}"
|
||||
if [[ -z "$FOLDER" || "$FOLDER" != /* || "${#_depth}" -lt 3 ]]; then
|
||||
error "Refusing to clean unsafe path: '${FOLDER:-empty}' — expected an absolute path at least 3 levels deep"
|
||||
notify "Media cleaner ($PROFILE) refused unsafe path on $(hostname): '${FOLDER:-empty}'" \
|
||||
"Media Cleaner" "warning"
|
||||
FAILED+=("${FOLDER_NAME:-empty}")
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! -d "$FOLDER" ]]; then
|
||||
warn "$FOLDER_NAME not found — skipping"
|
||||
SKIPPED+=("$FOLDER_NAME")
|
||||
|
||||
@@ -14,6 +14,30 @@
|
||||
# or unRAID environment resets after updates.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# For each share in MEDIA_PERMISSION_SHARES:
|
||||
#
|
||||
# 1. Path safety and existence
|
||||
# → unsafe or missing paths are refused or skipped, never scanned
|
||||
#
|
||||
# 2. Count wrong ownership (diagnostic)
|
||||
# → find ! -user / ! -group — the number reported as "corrected"
|
||||
#
|
||||
# 3. Ownership pass — only if the count is non-zero
|
||||
# → chown PERMISSIONS_OWNER on non-matching entries only
|
||||
#
|
||||
# 4. Directory mode pass
|
||||
# → chmod PERMISSIONS_DIR_MODE on directories not already at that mode
|
||||
#
|
||||
# 5. File mode pass
|
||||
# → chmod PERMISSIONS_FILE_MODE on files not already at that mode
|
||||
# → "No such file" errors ignored: volatile dirs (Emby transcodes) race
|
||||
#
|
||||
# Every pass is conditional by design — see Conditional Passes below.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
@@ -35,16 +59,53 @@
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# acquire_lock "wait" — wait if previous run still active (large share scans)
|
||||
# detect_hosts() — correct share list per host via MY_ID aliases
|
||||
# Empty array guard — warns and exits cleanly if no shares configured
|
||||
# Folder existence — skips missing shares with warning, continues others
|
||||
# Separate passes — directories and files chmod'd separately for correctness
|
||||
# Conditional passes — only entries whose owner/mode is actually wrong are touched.
|
||||
# chown/chmod restamp ctime even when the value doesn't change,
|
||||
# and the arr cleanups gate orphan deletion on ctime
|
||||
# platform_require_cmd — notify script validated before use
|
||||
# Silent by default — only failures produce output, success is silent
|
||||
# Root Enforcement
|
||||
# chown to an arbitrary owner requires root.
|
||||
#
|
||||
# Lock Acquisition
|
||||
# acquire_lock "wait" — waits rather than skipping. Share scans are long, and
|
||||
# this runs first in the daily window; skipping it would let arr cleanup run
|
||||
# against uncorrected ownership.
|
||||
#
|
||||
# Host Detection
|
||||
# detect_hosts() aliases HOST*_MEDIA_PERMISSION_SHARES to this host's shares.
|
||||
#
|
||||
# Empty Array Guard
|
||||
# Exits cleanly if no shares are configured for this host.
|
||||
#
|
||||
# Share Path Depth Guard
|
||||
# Every share must be an absolute path at least three levels deep before it is
|
||||
# scanned. A truncated entry like /mnt/user passes an existence check and would
|
||||
# chown and chmod every share on the array — which, because chown/chmod restamp
|
||||
# ctime, would erase the age signal the arr cleanups depend on across the whole
|
||||
# library in a single run.
|
||||
#
|
||||
# Folder Existence
|
||||
# Missing shares are skipped with a warning; remaining shares still process.
|
||||
#
|
||||
# Separate Passes
|
||||
# Directories and files are chmod'd in separate passes — directories need the
|
||||
# execute bit for traversal, media files must not have it.
|
||||
#
|
||||
# Conditional Passes
|
||||
# Only entries whose owner or mode is actually wrong are touched. This is not
|
||||
# an optimisation: chown/chmod rewrite an inode's ctime even when the value is
|
||||
# unchanged, so a blanket pass would restamp every file nightly and destroy
|
||||
# ctime as an age signal. The arr cleanups gate orphan deletion on ctime, and
|
||||
# mtime cannot substitute — imports preserve the release's original timestamp.
|
||||
# Making any pass unconditional silently stops orphan collection.
|
||||
#
|
||||
# Transcode Race Tolerance
|
||||
# "No such file or directory" errors from the file pass are ignored. Volatile
|
||||
# directories such as Emby transcodes delete files mid-scan; that is expected,
|
||||
# not a permissions failure.
|
||||
#
|
||||
# Dry Run Support
|
||||
# --dry-run counts the dirs, files and ownership entries that would change and
|
||||
# modifies nothing.
|
||||
#
|
||||
# Silent by Default
|
||||
# Only failures and diagnostics produce output; a clean run is quiet.
|
||||
#
|
||||
# Diagnostic — high corrected count on every run means a container has wrong PUID/PGID:
|
||||
# Correct values on unRAID: PUID=99 (nobody) PGID=100 (users)
|
||||
@@ -153,6 +214,18 @@ PERMISSIONS_GROUP="${PERMISSIONS_OWNER##*:}"
|
||||
for SHARE in "${MEDIA_PERMISSION_SHARES[@]}"; do
|
||||
SHARE_NAME=$(basename "$SHARE")
|
||||
|
||||
# A truncated entry such as /mnt/user passes the -d check below and would chown/chmod
|
||||
# every share on the array. Because chown/chmod restamp ctime, that would erase the age
|
||||
# signal the arr cleanups gate orphan deletion on — across the whole library, in one run.
|
||||
_depth="${SHARE//[^\/]/}"
|
||||
if [[ -z "$SHARE" || "$SHARE" != /* || "${#_depth}" -lt 3 ]]; then
|
||||
error "Refusing to touch unsafe path: '${SHARE:-empty}' — expected an absolute path at least 3 levels deep"
|
||||
notify "Media permissions refused unsafe path on $(hostname): '${SHARE:-empty}'" \
|
||||
"Media Permissions" "warning"
|
||||
FAILED+=("${SHARE_NAME:-empty}")
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! -d "$SHARE" ]]; then
|
||||
warn "$SHARE_NAME not found — skipping"
|
||||
SKIPPED+=("$SHARE_NAME")
|
||||
|
||||
+72
-11
@@ -18,7 +18,7 @@
|
||||
# Audio → MusicBrainz Track ID
|
||||
#
|
||||
# ==============================================================================================
|
||||
# SYNC LOGIC
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# For each matched item across ≥2 servers:
|
||||
@@ -55,21 +55,71 @@
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PLAY_SYNC_ENABLED gate — exits cleanly when disabled; no partial runs
|
||||
# PARTNERSHIP gate — skips remote sync when partnership is inactive
|
||||
# Per-server reachability — unreachable servers are skipped individually;
|
||||
# one offline server does not abort the entire sync
|
||||
# User match required — a user missing from a server is skipped for that
|
||||
# server; no cross-account state pollution
|
||||
# acquire_lock — prevents concurrent runs from racing on the same
|
||||
# items during the 30-minute critical window
|
||||
# Root Enforcement
|
||||
# The probe fingerprint is written under STATE_DIR, which is not user-writable.
|
||||
# Without root the fingerprint silently fails to persist and the change probe
|
||||
# never suppresses anything.
|
||||
#
|
||||
# jq Dependency Check
|
||||
# Exits if jq is missing. All API response parsing and the epoch comparisons
|
||||
# depend on it — without jq every comparison would silently evaluate empty.
|
||||
#
|
||||
# PLAY_SYNC_ENABLED Gate
|
||||
# Exits cleanly when disabled; no partial runs.
|
||||
#
|
||||
# PLAY_SYNC_REMOTE Gate
|
||||
# When false, only this host's own servers are synced. Remote hosts are skipped
|
||||
# before any network call is attempted.
|
||||
#
|
||||
# Partnership Gate
|
||||
# Remote hosts are skipped when PARTNERSHIP_ENABLED=false. Local Emby↔Jellyfin
|
||||
# sync still runs — a dormant partnership does not disable local work.
|
||||
#
|
||||
# Tailscale Resolution Guard
|
||||
# A remote host whose Tailscale IP cannot be resolved is skipped rather than
|
||||
# contacted at its literal localhost URL, which would otherwise point the sync
|
||||
# at this host's own server and cross-contaminate state.
|
||||
#
|
||||
# Placeholder Credential Guard
|
||||
# Servers whose API key is empty or still a placeholder are dropped from the
|
||||
# list before any request is made.
|
||||
#
|
||||
# Per-Server Reachability
|
||||
# Unreachable servers are skipped individually; one offline server does not
|
||||
# abort the entire sync.
|
||||
#
|
||||
# User Match Required
|
||||
# A user missing from a server is skipped for that server. State is never
|
||||
# written to an unrelated account that happens to exist there.
|
||||
#
|
||||
# Forward-Only Writes
|
||||
# The sync only pushes state forward — it never clears a Played flag or resets
|
||||
# a resume position. The worst outcome of a bad comparison is a no-op, not
|
||||
# erased watch history.
|
||||
#
|
||||
# Lock Acquisition
|
||||
# acquire_lock prevents concurrent runs racing on the same items during the
|
||||
# 30 minute critical window. --wait switches from skip to wait for manual runs.
|
||||
#
|
||||
# Probe Staleness Ceiling
|
||||
# PLAY_SYNC_PROBE_MAX_AGE_HOURS forces a full comparison regardless of the
|
||||
# hash. Fetches happen every run either way, so the probe can only skip
|
||||
# per-item processing — it can never cause a change to be missed outright.
|
||||
#
|
||||
# Dry Run Support
|
||||
# --dry-run performs all comparisons and writes no state.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION (host*.conf, aliased by detect_hosts)
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_TRANSCODE_SERVERS "Name|URL|APIKey|type" entries per host (emby/jellyfin)
|
||||
# All hosts are discovered automatically — no extra config needed.
|
||||
# Read directly for every HOST[0-9]+ defined — this script
|
||||
# deliberately does NOT call detect_hosts(), because it needs
|
||||
# every host's servers, not just this one's. Self is identified
|
||||
# by comparing HOST* values against hostname -s.
|
||||
# Remote host URLs have localhost rewritten to their Tailscale IP.
|
||||
#
|
||||
# master.conf
|
||||
@@ -103,6 +153,10 @@
|
||||
# play_state_sync.sh --full
|
||||
# Bypass the change probe — always run the full comparison.
|
||||
#
|
||||
# play_state_sync.sh --wait
|
||||
# Wait for an in-progress run to finish instead of exiting. For manual runs
|
||||
# that would otherwise be skipped by the every-30-minute scheduled pass.
|
||||
#
|
||||
# play_state_sync.sh --log
|
||||
# Verbose output — show each item comparison.
|
||||
#
|
||||
@@ -129,6 +183,13 @@ parse_args "${_FILTERED[@]}"
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
# The probe fingerprint lives under STATE_DIR — without root it silently fails to persist
|
||||
# and the change probe can never suppress a run.
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0
|
||||
|
||||
SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode}"
|
||||
|
||||
Reference in New Issue
Block a user