#!/usr/bin/env bash # lib/common.sh — shared helpers (logging, errors, validation). # Sourced by all ssr commands. Never executed directly. set -euo pipefail # Resolve repo root from any sourced location. SSR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SSR_ROOT="$(cd "${SSR_LIB_DIR}/.." && pwd)" export SSR_ROOT SSR_LIB_DIR # Colors only when stdout is a TTY (no ANSI in launchd logs). if [[ -t 1 ]]; then SSR_C_RED=$'\033[31m'; SSR_C_GRN=$'\033[32m' SSR_C_YLW=$'\033[33m'; SSR_C_BLU=$'\033[34m' SSR_C_DIM=$'\033[2m'; SSR_C_RST=$'\033[0m' else SSR_C_RED=""; SSR_C_GRN=""; SSR_C_YLW=""; SSR_C_BLU=""; SSR_C_DIM=""; SSR_C_RST="" fi ssr::ts() { date '+%Y-%m-%dT%H:%M:%S%z'; } ssr::log() { printf '%s %s[INFO]%s %s\n' "$(ssr::ts)" "$SSR_C_BLU" "$SSR_C_RST" "$*"; } ssr::ok() { printf '%s %s[OK]%s %s\n' "$(ssr::ts)" "$SSR_C_GRN" "$SSR_C_RST" "$*"; } ssr::warn() { printf '%s %s[WARN]%s %s\n' "$(ssr::ts)" "$SSR_C_YLW" "$SSR_C_RST" "$*" >&2; } ssr::err() { printf '%s %s[ERR]%s %s\n' "$(ssr::ts)" "$SSR_C_RED" "$SSR_C_RST" "$*" >&2; } ssr::die() { ssr::err "$@"; exit 1; } # Mirror to syslog if available so launchd runs are findable via `log show`. ssr::syslog() { [[ -x /usr/bin/logger ]] || return 0 /usr/bin/logger -t "ssr" -- "$*" || true } ssr::require_macos() { [[ "$(uname -s)" == "Darwin" ]] || ssr::die "ssr requires macOS (Darwin). Detected: $(uname -s)" } ssr::require_cmd() { command -v "$1" >/dev/null 2>&1 || ssr::die "Required command missing: $1" } # Confirm prompt with optional timeout (returns 0 = yes). ssr::confirm() { local prompt="${1:-Continue?}" timeout="${2:-0}" reply if [[ "$timeout" -gt 0 ]]; then read -r -t "$timeout" -n 1 -p "${prompt} [y/N]: " reply || reply="" else read -r -n 1 -p "${prompt} [y/N]: " reply || reply="" fi echo case "$reply" in y|Y) return 0 ;; *) return 1 ;; esac } # Install or replace $1 with the contents of regular file $2 (staging path). # - Missing path or dangling symlink: writes immediately (no overwrite). # - Same bytes as staging: no-op, return 0. # - Differs: prints unified diff (existing → proposed) on stderr, then either # prompts on a TTY or requires SSR_ALLOW_CONF_OVERWRITE=yes (non-interactive). # Returns 0 after a successful write or no-op; 1 if the user declined or # non-interactive without SSR_ALLOW_CONF_OVERWRITE. ssr::replace_file_with_confirm() { local target="$1" staging="$2" ssr::validate_path "$target" ssr::validate_path "$staging" [[ -f "$staging" ]] || ssr::die "Staging file not found: $staging" [[ -d "$target" ]] && ssr::die "Refusing to replace a directory: $target" # Nothing there yet (or dangling symlink) — create without prompt. if [[ ! -e "$target" ]]; then rm -f "$target" cat "$staging" > "$target" ssr::ok "Created $target" return 0 fi if ! [[ -f "$target" ]]; then ssr::die "Refusing to replace non-regular file: $target" fi if cmp -s "$target" "$staging" 2>/dev/null; then return 0 fi printf '\n' >&2 ssr::log "Proposed change to $target (unified diff: existing → proposed)" diff -u "$target" "$staging" >&2 || true printf '\n' >&2 case "${SSR_ALLOW_CONF_OVERWRITE:-}" in yes|1|true|on) ssr::log "SSR_ALLOW_CONF_OVERWRITE set — applying change to $target" ;; *) if ! [[ -t 0 ]]; then ssr::warn "Refusing to overwrite $target (stdin is not a TTY)." ssr::warn "Review the diff above, merge manually, or run once with:" ssr::warn " SSR_ALLOW_CONF_OVERWRITE=yes ssr sync # or ssr restore" return 1 fi ssr::confirm "Replace $target as shown in the diff above?" 0 || { ssr::warn "Leaving $target unchanged." return 1 } ;; esac cp -f "$target" "$target.bak.$(date +%s)" 2>/dev/null || true rm -f "$target" cat "$staging" > "$target" ssr::ok "Updated $target (previous saved as ${target}.bak.*)" return 0 } # Validate a path is safe (no newlines, no `..` traversal). Used before any FS op. # Note: bash strings cannot hold NUL bytes, so no separate NUL check needed. ssr::validate_path() { local p="$1" [[ -n "$p" ]] || ssr::die "Empty path" case "$p" in *$'\n'*) ssr::die "Path contains newline: $p" ;; *..*) ssr::die "Path traversal rejected: $p" ;; esac } ssr::ensure_dir() { local d="$1" ssr::validate_path "$d" [[ -d "$d" ]] || mkdir -p "$d" } # If ssr.conf lists SSR_CLOUD_PROVIDER more than once, rewrite # the file to a single assignment: same value repeated is merged silently; # different values prompt on a TTY (stores choice) or abort without TTY. ssr::config::resolve_cloud_provider_duplicates() { local cfg="$1" local -a vals=() local line while IFS= read -r line; do [[ "$line" =~ ^SSR_CLOUD_PROVIDER=(.*)$ ]] || continue vals+=("${BASH_REMATCH[1]}") done < <(grep '^SSR_CLOUD_PROVIDER=' "$cfg" 2>/dev/null || true) [[ ${#vals[@]} -le 1 ]] && return 0 local -a uniq=() local v seen u for v in "${vals[@]}"; do seen=0 for u in "${uniq[@]:-}"; do [[ "$u" == "$v" ]] && { seen=1; break; }; done [[ "$seen" -eq 0 ]] && uniq+=("$v") done local choice="" if [[ ${#uniq[@]} -eq 1 ]]; then choice="${uniq[0]}" ssr::log "Consolidating ${#vals[@]} SSR_CLOUD_PROVIDER line(s) → $choice" elif [[ -t 0 ]]; then printf '\n' >&2 ssr::warn "Config $cfg defines multiple different SSR_CLOUD_PROVIDER values." local i=1 for v in "${uniq[@]}"; do printf ' %d) %s\n' "$i" "$v" >&2 i=$((i + 1)) done local pick while true; do read -r -p "Select provider to keep [1-${#uniq[@]}]: " pick || pick="" [[ "$pick" =~ ^[0-9]+$ ]] || { ssr::warn "Enter a number between 1 and ${#uniq[@]}."; continue; } (( pick >= 1 && pick <= ${#uniq[@]} )) || { ssr::warn "Out of range."; continue; } choice="${uniq[$((pick - 1))]}" break done ssr::ok "SSR_CLOUD_PROVIDER=$choice will be written to $cfg" else ssr::die "Config $cfg has conflicting SSR_CLOUD_PROVIDER lines (${uniq[*]}). Keep one value or run ssr from a terminal to choose." fi local tmp newtmp bak tmp="$(mktemp "${TMPDIR:-/tmp}/ssr-cfg.XXXXXX")" newtmp="$(mktemp "${TMPDIR:-/tmp}/ssr-cfg.XXXXXX")" bak="${cfg}.bak.$(date +%s)" cp -p "$cfg" "$bak" 2>/dev/null || cp "$cfg" "$bak" grep -v '^SSR_CLOUD_PROVIDER=' "$cfg" > "$tmp" local ins=0 while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$ins" -eq 0 && "$line" == '# Provider:'* ]]; then printf '%s\n' "$line" printf 'SSR_CLOUD_PROVIDER=%s\n' "$choice" ins=1 continue fi printf '%s\n' "$line" done < "$tmp" > "$newtmp" [[ "$ins" -eq 1 ]] || printf 'SSR_CLOUD_PROVIDER=%s\n' "$choice" >> "$newtmp" mv "$newtmp" "$cfg" rm -f "$tmp" ssr::log "Config updated (previous copy: $bak)" } # Source config file if it exists. Validates that only known KEY=VALUE lines # are present to avoid arbitrary code execution from a tampered config. ssr::load_config() { local cfg="${1:-${SSR_CONFIG:-$HOME/.config/ssr/ssr.conf}}" export SSR_CONFIG="$cfg" [[ -f "$cfg" ]] || return 0 ssr::config::resolve_cloud_provider_duplicates "$cfg" if grep -Eq -v '^[[:space:]]*(#|$|[A-Z_][A-Z0-9_]*=)' "$cfg"; then ssr::die "Config $cfg contains invalid lines (only KEY=VALUE allowed)" fi # shellcheck disable=SC1090 source "$cfg" }