#!/bin/bash
# Host-side watcher: turns marker files dropped by hooks/dejima-notify.sh
# into ntfy pushes. Runs as a systemd USER unit on the host - never as root,
# never inside a container. See docs/remote-access.md.
#
# Usage: dejima-notify-watch [--once]
#   (no argument)  watch until killed - what the systemd unit runs
#   --once         one scan, then exit
#
# This script is the trust boundary: a possibly-compromised session writes
# the markers, and the phone they reach is outside the sandbox. The
# in-container hook's enum constrains nothing here - a session owns its
# marker directory and can create any name it likes. The guarantee is here:
#
#   - only the marker NAME is consulted, by iterating a literal enum, so a
#     name outside it is never stat'd, read, or removed
#   - marker CONTENT is never read, and names never reach a command
#   - `rm -f` unlinks a symlink rather than following it
#   - the click URL and every ntfy header are composed from host-side data
#
# The last point is what the enum is for: ntfy's X-Actions can make the phone
# issue an HTTP request or fire an Android intent, X-Attach and X-Icon fetch
# URLs. One attacker-controlled header makes the phone a confused deputy
# inside the operator's network.
#
# One scan covers every run directory, so nothing a session does to its own
# markers can abort it and silence every other session's notifications.
set -euo pipefail

# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
    -h|--help)
        cat <<'DEJIMA_NOTIFY_WATCH_HELP'
dejima-notify-watch - turn a session's notifications into phone pushes

Usage: dejima-notify-watch [--once]

  (no argument)  watch until killed, which is what the systemd unit runs
  --once         one scan, then exit

Started by a systemd USER unit on the host. Never as root, never inside a
container.

A session's hooks drop marker files when it wants attention or has finished;
this turns those into ntfy pushes. It runs host-side because it is the trust
boundary: a possibly-compromised session writes the markers, and the phone they
reach is outside the sandbox. Nothing the session writes is used as a command,
a URL or a topic - the marker only says which of a fixed set of events happened.

Does nothing unless [remote-access] enabled is on and a notify URL is set.

See docs/remote-access.md.
DEJIMA_NOTIFY_WATCH_HELP
        exit 0 ;;
esac

SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"

if ! remote_enabled; then
    echo "Remote access is not configured (no [remote-access] enabled = on in $(config_path))." >&2
    echo "See README \"Remote access\"." >&2
    exit 1
fi

NTFY_URL="$(config_get remote-access ntfy_url)"
BASE_URL="$(config_get remote-access base_url)"
if [ -z "$NTFY_URL" ]; then
    echo "ntfy_url is not set in $(config_path) - nothing to push to." >&2
    exit 1
fi

# The url config type already requires an http(s) scheme, but this is a
# second check in the one place a bad value is dangerous rather than just
# wrong: curl reads "-K/path" as an OPTION, and -K takes the request's URL -
# and its output file - from that path instead, so a value that somehow
# isn't a URL sends the push somewhere the operator never named. The `--` at
# the call site is the other half.
#
# Refusing rather than sanitizing, because the config is operator-written:
# a value that can't be used as intended means they got something wrong, and
# this is the one place they say where their phone notifications go.
#
# The value is not echoed back whole. An ntfy server that wants credentials
# takes them in the URL - https://<user>:<token>@<host>/<topic>, which
# docs/remote-access.md documents - and a misconfiguration should not be the
# thing that writes that token into the journal, where it stays.
redacted_url() {
    case "$1" in
        *://*@*) printf '%s://<redacted>@%s' "${1%%://*}" "${1#*@}" ;;
        *) printf '%s' "$1" ;;
    esac
}

require_url() {
    case "$2" in
        http://*|https://*) ;;
        *)
            echo "$1 in $(config_path) must be an http:// or https:// URL: $(redacted_url "$2")" >&2
            exit 1
            ;;
    esac
    # The URL is handed to curl through a config file on stdin rather than as
    # an argument (see the push below), and that format is line-oriented with
    # its own quoting: a newline in the value would start a second directive,
    # and a quote or a backslash would end or escape the string. The `url`
    # config type's own character set already excludes all three, so this
    # refuses a value that arrived some other way rather than sanitizing one
    # that did - the same reading as the scheme check above.
    case "$2" in
        *[!A-Za-z0-9:/._~?=%@+-]*)
            echo "$1 in $(config_path) has a character dejima will not send: $(redacted_url "$2")" >&2
            exit 1
            ;;
    esac
}
require_url ntfy_url "$NTFY_URL"
[ -z "$BASE_URL" ] || require_url base_url "$BASE_URL"

STATE_DIR="$(remote_state_dir)"
NOTIFY_DIR="$STATE_DIR/notify"
DEBOUNCE_DIR="$STATE_DIR/debounce"
DEBOUNCE_SECONDS="$(config_get remote-access debounce)"

# Same check bin/dejima makes before creating this, and for the same reason:
# /tmp is the fallback parent when XDG_RUNTIME_DIR is unset, and
# `mkdir -p -m 700` only sets the mode of the leaf it creates - a symlink
# planted here would send every mkdir and every rm below it into someone
# else's tree. Created rather than required, so the unit can start before the
# first sandbox has ever run.
# shellcheck disable=SC2174
mkdir -p -m 700 "$STATE_DIR" 2>/dev/null || true
if [ -L "$STATE_DIR" ] || [ ! -d "$STATE_DIR" ] \
    || [ "$(stat -c %u "$STATE_DIR")" != "$(id -u)" ]; then
    echo "Refusing to watch: $STATE_DIR is a symlink, or is not a directory owned" >&2
    echo "by the current user. Remove it, or set XDG_RUNTIME_DIR somewhere else." >&2
    exit 1
fi
# -m 700 above applies only when this process is the one that creates it, and
# either side may get there first, so the mode is set unconditionally here -
# same as bin/dejima:491.
chmod 700 "$STATE_DIR"
# The parent is verified and chmod 700 above, so -p creates only these two
# leaves and -m is the mode each of them gets.
# shellcheck disable=SC2174
mkdir -p -m 700 "$NOTIFY_DIR" "$DEBOUNCE_DIR"

# From the registry (host-written), not from anything the session wrote. Still
# reduced to a safe charset: the workspace path is operator-chosen, and a
# newline in it would split a curl -H header.
project_of() {
    local run_id="$1" file line raw="" name
    file="$STATE_DIR/sessions/$run_id"
    # An entry that can't be opened must degrade to an unnamed push, never to
    # a failure: one scan covers every run directory, so aborting here would
    # take every other session's notification down with it. `|| raw=""` is
    # what guarantees that - a failed redirection makes the compound command
    # return nonzero, and the || consumes it. Errexit is not the mechanism:
    # the sole caller is push(), which reaches this through a command
    # substitution, and bash does not inherit errexit into one absent shopt
    # inherit_errexit. The [ -r ] check is therefore about noise, not control
    # flow - and not much noise either, since push() runs only for a marker
    # that was consumed and not debounced: without it, a chmod'd entry prints
    # "Permission denied" once per push for that session.
    if [ -r "$file" ]; then
        while IFS= read -r line || [ -n "$line" ]; do
            case "$line" in
                workspace=*) raw="${line#*=}"; break ;;
            esac
        done < "$file" || raw=""
    fi
    name="$(basename -- "$raw" | tr -cd 'A-Za-z0-9._-' | cut -c1-64)"
    printf '%s' "${name:-unknown}"
}

push() {
    local run_id="$1" event="$2" project title body
    local -a click=()
    project="$(project_of "$run_id")"
    case "$event" in
        waiting) title="dejima: $project needs input"; body="Session is waiting for you." ;;
        done)    title="dejima: $project finished";    body="Session has stopped." ;;
        *) return 0 ;;
    esac
    # $run_id here came from the DIRECTORY NAME bin/dejima created and was
    # shape-validated by valid_run_id - never from marker content.
    if [ -n "$BASE_URL" ]; then
        click=(-H "Click: ${BASE_URL%/}/?arg=$run_id")
    fi
    # stdout is discarded (ntfy answers with a JSON receipt); stderr is not.
    # -sS exists precisely to keep transport errors on stderr, and under a
    # systemd user unit that is the journal, which is where they belong.
    #
    # The URL goes in on stdin, not in argv. It is the one value here that can
    # carry a credential, and every argument of every process on this host is
    # readable by any local account through /proc/<pid>/cmdline - so a token in
    # argv is a token published to the machine for as long as the request runs,
    # and `ps` is enough to read it. Nothing else on this line is secret: the
    # title, the body and the click URL are all composed from host data.
    #
    # This also retires the `-K/path` hazard the scheme check above was half
    # of: a URL that is not a URL can no longer be read as an option, because
    # it is no longer in the option list at all. Both checks stay anyway, and
    # the second one is now what keeps this format from gaining a directive.
    # Redirected from a process substitution rather than piped into. `printf |
    # curl` is a pipeline under `set -o pipefail`, so a curl that exits before
    # draining stdin leaves printf holding a closed pipe and the pipeline
    # reports 141 - a "push failed" line for a push that succeeded. A
    # redirection is not a pipeline and has no such status to inherit. It also
    # keeps `--config -` in argv rather than a /dev/fd path, which is a fixed
    # string a test can compare.
    curl -fsS --max-time 10 --retry 2 \
        -H "Title: $title" \
        -H "Priority: default" \
        "${click[@]}" \
        -d "$body" \
        --config - >/dev/null < <(printf 'url = "%s"\n' "$NTFY_URL") \
        || echo "dejima-notify-watch: push failed for $run_id/$event" >&2
}

# Persisted rather than in-memory so --once (and a restarted unit) debounce
# the same way a long-running process does.
debounced() {
    local key="$1" now="$2" last
    last="$(cat "$DEBOUNCE_DIR/$key" 2>/dev/null || true)"
    # A non-numeric value in $(( )) is evaluated as a VARIABLE NAME, and any
    # command substitution in its subscript runs - "now[$(cmd)]" executes cmd.
    # set -u does not catch it, because `now` is bound right here. Digits
    # alone still aren't enough either: a leading zero is read as octal, so
    # 10# forces base 10 (and independently refuses to lex a name).
    #
    # The shape check leads because 10# only turns execution into an
    # arithmetic *error*, and a bash arithmetic error unwinds to the top
    # level: it discards the rest of the enclosing top-level command, so the
    # `exit 0` after a --once scan never runs and control falls through into
    # the watch loop below. Refusing the value keeps that unwind unreachable.
    #
    # Junk reads as "not sent recently" rather than as "sent": failing the
    # other way would let one unparseable file silence a session for good.
    case "$last" in
        ''|*[!0-9]*) return 1 ;;
    esac
    [ "$(( now - 10#$last ))" -lt "$DEBOUNCE_SECONDS" ]
}

declare -A WARNED=()

scan() {
    local dir run_id event marker now
    now="$(date +%s)"
    for dir in "$NOTIFY_DIR"/*; do
        [ -d "$dir" ] || continue
        run_id="$(basename -- "$dir")"
        # Only directories bin/dejima could have created. Anything else is
        # skipped without being looked into at all.
        valid_run_id "$run_id" || continue
        # The enum is iterated, not discovered: a name that isn't one of these
        # is never stat'd, never read, never removed.
        for event in waiting 'done'; do
            marker="$dir/$event"
            # -e follows the link and is FALSE for a dangling symlink, which a
            # session can plant as easily as a file; -L catches those so they
            # are cleaned up too, without their target being created.
            [ -e "$marker" ] || [ -L "$marker" ] || continue
            # Consumed before it is acted on, and non-fatally: the marker
            # directory is bind-mounted read-write, so a session can make its
            # own marker undeletable (a non-empty directory named `waiting` is
            # enough), and dying here would silence every OTHER session too.
            # Skipped rather than pushed anyway, because a marker that
            # survives would otherwise re-push on every scan from now on.
            if ! rm -f "$marker"; then
                # Once per marker per process, not once per scan: the marker
                # survives, so every later scan finds it again, and a session
                # that leaves one in place could otherwise fill the journal
                # for as long as the watcher runs. Permanently, deliberately -
                # a marker that becomes deletable and then undeletable again
                # is the same stuck session, not news. The key cannot be
                # inflated to grow this map: scan() iterates only the top
                # level of $NOTIFY_DIR against a two-name enum, and a session
                # can create directories only inside its own run directory,
                # so it is bounded at twice the number of run directories.
                if [ -z "${WARNED[$run_id/$event]:-}" ]; then
                    echo "dejima-notify-watch: warning: could not consume marker $run_id/$event" >&2
                    WARNED[$run_id/$event]=1
                fi
                continue
            fi
            if debounced "$run_id-$event" "$now"; then
                continue
            fi
            printf '%s' "$now" > "$DEBOUNCE_DIR/$run_id-$event"
            push "$run_id" "$event"
        done
    done
}

ONCE=""
case "${1:-}" in
    --once) ONCE=1 ;;
    "") ;;
    *)
        echo "usage: ${0##*/} [--once]" >&2
        exit 2
        ;;
esac

if [ -n "$ONCE" ]; then
    scan
fi
# A separate top-level command from the scan above, deliberately: a bash
# arithmetic error unwinds to the top level and discards the REST of the
# enclosing top-level command, so a `scan; exit 0` pair sharing one `if` can
# lose its exit and run the watch loop instead of returning. Nothing should
# reach such an error (debounced() refuses a non-numeric stamp before the
# arithmetic), but a --once run that never exits is a bad way to find out.
[ -z "$ONCE" ] || exit 0

# inotify-tools is optional: without it this degrades to a ~6s poll rather
# than failing - the 1s floor below plus the 5s fallback sleep, which is the
# interval a scan actually repeats at. The -t 30 timeout also closes the race
# where a marker lands in a run directory created after the current watch was
# established.
while :; do
    scan
    # A floor under the loop, unconditional and ahead of the wait: inotify
    # wakes on the sandbox's OWN file creations, so without it a session that
    # creates files in a loop drives one scan per creation for as long as it
    # likes. One scan a second is far more than the notifications need.
    sleep 1
    if command -v inotifywait >/dev/null 2>&1; then
        # A failure here is reachable from inside a sandbox rather than rare:
        # -r takes one inotify watch per directory below $NOTIFY_DIR, and a
        # session owns its own marker directory, so it can mkdir enough of
        # them to exhaust this uid's max_user_watches - after which every
        # inotifywait returns immediately. Falling back to the poll interval
        # costs a little latency and bounds it; `|| true` would spin.
        inotifywait -qq -r -t 30 -e create -e moved_to "$NOTIFY_DIR" >/dev/null 2>&1 \
            || sleep 5
    else
        sleep 5
    fi
done
