#!/bin/bash
# Single source of truth for aligning volume ownership with the runtime user.
#
# Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH [PATH...]
#
#   --dry-run    report what would change, touch nothing
#   --sentinel   skip entirely when FILE already records "SCHEMA:UID:GID";
#                write it after a successful run (used by the boot path so
#                multi-TB volumes are swept once per UID/schema change, not
#                on every boot)
#   --mode       append STRING to the sentinel, so changing it re-sweeps once
#
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
# lost+found is skipped: fsck fills it with root-only recovered fragments.
# Top-level /config additionally grants group frigate-data TRAVERSE ONLY
# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file
# on hosts where /config is mounted 0700. Never g+w: directory write means
# unlink rights over frigate.db/config.yml, and would let a compromised
# go2rtc plant /config/go2rtc, which the go2rtc run script executes
# preferentially, as root under the escape hatch.

set -o errexit -o nounset -o pipefail

# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=2

dry_run=0
sentinel=""
mode=""

while [[ "${1:-}" == --* ]]; do
    case "$1" in
        --dry-run) dry_run=1; shift ;;
        --sentinel)
            if [[ -z "${2:-}" ]]; then
                echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2
                exit 2
            fi
            sentinel="$2"; shift 2 ;;
        --mode)
            if [[ -z "${2:-}" ]]; then
                echo "[ERROR] fix-ownership: --mode requires a value" >&2
                exit 2
            fi
            mode="$2"; shift 2 ;;
        *) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;;
    esac
done

if [[ $# -lt 3 ]]; then
    echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH..." >&2
    exit 2
fi

target_uid="$1"
target_gid="$2"
shift 2

if [[ "$(id -u)" -ne 0 ]]; then
    echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)"
    exit 0
fi

# The list folds into the sentinel so entering or leaving a granular root mode
# re-sweeps once, catching whatever the other ownership mechanisms missed.
sentinel_content="${schema}:${target_uid}:${target_gid}"
if [[ -n "$mode" ]]; then
    sentinel_content="${sentinel_content}:${mode}"
fi

# safe-sentinel reports only a root-owned regular file, so a forged or
# symlinked sentinel in the runtime-user-owned /config can't suppress the sweep
if [[ "$dry_run" -eq 0 && -n "$sentinel" ]]; then
    if existing=$(/usr/local/bin/safe-sentinel read "$sentinel" 2>/dev/null) && \
        [[ "$existing" == "$sentinel_content" ]]; then
        echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
        exit 0
    fi
fi

# A sweep that could not chown everything must not be recorded as complete:
# the sentinel would make every later boot skip it and the entries would stay
# unreachable once services run unprivileged.
swept_clean=1

# Entries another mechanism deliberately owns. Chowning them undoes that work
# and leaves the same "mismatch" waiting for the next boot, so /config could
# never report itself clean: /config is chgrp'd to frigate-data below so go2rtc
# can traverse it, and the HomeKit file is handed to the go2rtc user by the
# go2rtc service. Only the GROUP on /config is exempt; a root-owned /config
# must still be chowned or the runtime user cannot write there at all.
# Shared by the counting and the chowning walk so the two cannot disagree.
mismatch_expr=(
    "(" -not -uid "$target_uid"
        -o "(" -not -gid "$target_gid" -a ! -path /config ")"
    ")"
    -a ! -path /config/go2rtc_homekit.yml
)
if [[ -n "$sentinel" ]]; then
    # safe-sentinel keeps the sentinel root-owned on purpose and rejects one
    # owned by anybody else, so chowning it here would suppress the skip and
    # make every boot re-sweep. Only the trailing write puts it back today.
    mismatch_expr+=(-a ! -path "$sentinel")
fi

for path in "$@"; do
    # An absent root is an incomplete sweep, not a finished one: /media/frigate
    # is not in the image, so a boot before the volume is mounted would
    # otherwise record success and the volume would never be swept once added.
    if [[ ! -d "$path" ]]; then
        swept_clean=0
        echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot"
        continue
    fi

    echo "[INFO] fix-ownership: scanning ${path} for ownership mismatches; this may take a while on large filesystems"

    # find may fail mid-walk on a live volume (file deleted under it) or on a
    # stale mount. Tolerate it rather than aborting under errexit, but never
    # read a failed scan as "nothing to do": that would record the sweep as
    # complete without having looked.
    if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
        swept_clean=0
        echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
        continue
    fi

    if [[ "$count" -eq 0 ]]; then
        echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do"
        continue
    fi

    # find does not descend symlinks and chown -h retargets the link itself, so
    # anything behind a symlinked directory is outside this sweep. Following
    # them is not an option: a link could walk the chown out of the volume.
    if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then
        echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
    fi

    echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}"
    if [[ "$dry_run" -eq 1 ]]; then
        echo "[INFO] fix-ownership: dry run, not changing ${path}"
        continue
    fi

    # -execdir chowns from the entry's own directory, so a parent swapped for a
    # symlink mid-walk can't redirect the chown out of the volume
    started=$SECONDS
    if find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" \
        -print -execdir chown -h "${target_uid}:${target_gid}" {} + \
        | awk -v total="$count" -v path="$path" '
            BEGIN { next_pct = 5 }
            {
                pct = int(NR * 100 / total)
                if (pct > 100) pct = 100
                if (pct >= next_pct) {
                    printf "[INFO] fix-ownership: %s %d%% (%d/%d entries)\n", path, pct, NR, total
                    # mawk block-buffers to a pipe; without fflush the whole
                    # progress log arrives at once
                    fflush()
                    while (next_pct <= pct) next_pct += 5
                }
            }'; then
        elapsed=$((SECONDS - started))
        if [[ "$elapsed" -ge 60 ]]; then
            elapsed="$((elapsed / 60))m $((elapsed % 60))s"
        else
            elapsed="${elapsed}s"
        fi
        echo "[INFO] fix-ownership: finished ${path} in ${elapsed}"
    else
        swept_clean=0
        echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
    fi
done

# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites
# the first -config file via os.WriteFile (in-place truncate, no rename,
# verified against go2rtc v1.9.14 internal/app/config.go), and the file is
# always pre-created by setup_homekit_config before go2rtc starts, so
# O_CREATE never needs directory write. See header comment for why g+w is
# forbidden here.
if [[ "$dry_run" -eq 0 && -d /config ]]; then
    chgrp frigate-data /config 2>/dev/null || true
    chmod g+rx /config 2>/dev/null || true
fi

if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
    /usr/local/bin/safe-sentinel write "$sentinel" "$sentinel_content" || \
        echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
fi
