#!/bin/sh set -eu IFS=' ' SCRIPT_NAME=$(basename "$0") STAGE_ROOT=${SYNC_STAGE_ROOT:-/home/marco/regalamiunsorriso} LIVE_ROOT=${SYNC_LIVE_ROOT:-/home/sites/regalamiunsorriso} STAGE_USER=${SYNC_STAGE_USER:-marco} INCOMING_ROOT=${SYNC_INCOMING_ROOT:-$STAGE_ROOT/incoming} LOG_FILE=${SYNC_LOG_FILE:-$STAGE_ROOT/logs/promote-sync.log} PERMS_DB=${SYNC_PERMS_DB:-$STAGE_ROOT/metadata/perms.jsonl} MODE= PROMOTE_EXECUTE=0 INIT_EXECUTE=0 ONLY_SCOPE= RESOLVED_LIVE_ROOT= UPDATED_COUNT=0 SKIPPED_COUNT=0 FAILED_COUNT=0 usage() { cat <] [--perms-db ] [--log ] $SCRIPT_NAME --apply [--only ] [--perms-db ] [--log ] $SCRIPT_NAME --init-sync [--only ] [--perms-db ] [--log ] $SCRIPT_NAME --init-sync --apply-init [--only ] [--perms-db ] [--log ] Modes: --dry-run Preview staged file promotion into the live tree. --apply Promote staged files into the live tree. --init-sync Preview initial copy from live tree into staging. --apply-init Execute the initial copy when used with --init-sync. Options: --only Limit processing to one scope: www, rus, WEB-INF/classes. --perms-db Metadata database location. Default: $PERMS_DB --log Log file location. Default: $LOG_FILE --stage-root Override staging root. Default: $STAGE_ROOT --live-root Override live root. Default: $LIVE_ROOT --stage-user Override staging owner. Default: $STAGE_USER --help Show this help. Environment overrides: SYNC_STAGE_ROOT, SYNC_LIVE_ROOT, SYNC_STAGE_USER, SYNC_INCOMING_ROOT, SYNC_LOG_FILE, SYNC_PERMS_DB EOF } timestamp_utc() { date -u +"%Y-%m-%dT%H:%M:%SZ" } fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } ensure_log_dir() { mkdir -p "$(dirname -- "$LOG_FILE")" } log_line() { ensure_log_dir printf '%s\n' "$1" | tee -a "$LOG_FILE" } stat_owner() { if stat -f '%Su' "$1" >/dev/null 2>&1; then stat -f '%Su' "$1" else stat -c '%U' "$1" fi } stat_group() { if stat -f '%Sg' "$1" >/dev/null 2>&1; then stat -f '%Sg' "$1" else stat -c '%G' "$1" fi } stat_mode() { _mode= if stat -f '%Lp' "$1" >/dev/null 2>&1; then _mode=$(stat -f '%Lp' "$1") else _mode=$(stat -c '%a' "$1") fi printf '%04d\n' "$_mode" } resolve_existing_path() { if command -v realpath >/dev/null 2>&1; then realpath "$1" return fi _dir_path=$(dirname -- "$1") _base_name=$(basename -- "$1") ( cd "$_dir_path" printf '%s/%s\n' "$(pwd -P)" "$_base_name" ) } is_under_root() { case "$1" in "$2"|"$2"/*) return 0 ;; *) return 1 ;; esac } require_root() { if [ "$(id -u)" -ne 0 ]; then fail "this mode must run under sudo or as root" fi } is_allowed_scope() { case "$1" in www|rus|WEB-INF/classes) return 0 ;; *) return 1 ;; esac } run_for_selected_scopes() { _callback=$1 if [ -n "$ONLY_SCOPE" ]; then "$_callback" "$ONLY_SCOPE" return fi "$_callback" "www" "$_callback" "rus" "$_callback" "WEB-INF/classes" } is_excluded_relpath() { case "$1" in www/_img|www/_img/*|www/_news|www/_news/*|www/_tmp|www/_tmp/*|www/mypics|www/mypics/*|www/mypics-archivio|www/mypics-archivio/*|www/mypics2|www/mypics2/*) return 0 ;; *) return 1 ;; esac } is_safe_relpath() { case "$1" in ''|/*|../*|*/../*|*/..|*//*) return 1 ;; esac case "$1" in */../*|*/..) return 1 ;; *) return 0 ;; esac } json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } sha256_file() { if command -v sha256 >/dev/null 2>&1; then sha256 -q "$1" elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else printf '' fi } db_backup_existing() { _metadata_dir=$(dirname -- "$PERMS_DB") mkdir -p "$_metadata_dir" if [ -f "$PERMS_DB" ]; then _backup_path="$PERMS_DB.$(date -u +%Y%m%dT%H%M%SZ).bak" cp "$PERMS_DB" "$_backup_path" log_line "$(timestamp_utc) INFO perms-db-backup path=$_backup_path" fi } db_append_record() { printf '{"relpath":"%s","owner":"%s","group":"%s","mode":"%s","type":"%s","sha256":"%s"}\n' \ "$(json_escape "$1")" \ "$(json_escape "$2")" \ "$(json_escape "$3")" \ "$(json_escape "$4")" \ "$(json_escape "$5")" \ "$(json_escape "${6:-}")" >> "$PERMS_DB" } db_lookup_line() { awk -v target="\"relpath\":\"$1\"" ' index($0, target) { print found = 1 exit } END { if (!found) { exit 1 } } ' "$PERMS_DB" } db_extract_field() { printf '%s\n' "$1" | awk -F'"' -v lookup_key="$2" ' { for (i = 2; i < NF; i += 4) { if ($i == lookup_key) { print $(i + 2) exit } } } ' } user_exists() { id "$1" >/dev/null 2>&1 } group_exists() { if command -v getent >/dev/null 2>&1; then getent group "$1" >/dev/null 2>&1 return fi if command -v pw >/dev/null 2>&1; then pw groupshow "$1" >/dev/null 2>&1 return fi return 0 } record_live_metadata() { _live_path=$1 _relpath=$2 _entry_type=$3 _owner=$(stat_owner "$_live_path") _group=$(stat_group "$_live_path") _mode=$(stat_mode "$_live_path") _checksum= if [ "$_entry_type" = "file" ]; then _checksum=$(sha256_file "$_live_path") fi db_append_record "$_relpath" "$_owner" "$_group" "$_mode" "$_entry_type" "$_checksum" } log_skip() { SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) log_line "$(timestamp_utc) SKIP $1 reason=$2" } log_failure() { FAILED_COUNT=$((FAILED_COUNT + 1)) log_line "$(timestamp_utc) FAIL $1 reason=$2" } log_update() { UPDATED_COUNT=$((UPDATED_COUNT + 1)) log_line "$(timestamp_utc) $1 $2 -> $3 OK" } validate_path_policy() { if ! is_safe_relpath "$1"; then return 1 fi if is_excluded_relpath "$1"; then return 1 fi case "$1" in RUS|RUS/*) return 1 ;; *) return 0 ;; esac } ensure_parent_directory_exists() { [ -d "$(dirname -- "$1")" ] } copy_live_entry_to_stage() { _live_path=$1 _relpath=$2 _stage_path=$3 _entry_type=$4 if [ "$_entry_type" = "dir" ]; then mkdir -p "$_stage_path" chown "$STAGE_USER" "$_stage_path" chmod 0755 "$_stage_path" log_update "INIT-DIR" "$_relpath" "$_stage_path" return fi mkdir -p "$(dirname -- "$_stage_path")" cp "$_live_path" "$_stage_path" chown "$STAGE_USER" "$_stage_path" chmod 0644 "$_stage_path" log_update "INIT" "$_relpath" "$_stage_path" } run_init_scope() { _scope=$1 _live_scope_root="$LIVE_ROOT/$_scope" _stage_scope_root="$INCOMING_ROOT/$_scope" if [ ! -e "$_live_scope_root" ]; then log_skip "$_scope" "live-scope-missing" return fi _entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.init.XXXXXX") find "$_live_scope_root" \( -type d -o -type f -o -type l \) > "$_entry_list" while IFS= read -r _entry || [ -n "$_entry" ]; do _relpath=${_entry#"$LIVE_ROOT/"} if ! validate_path_policy "$_relpath"; then log_skip "$_relpath" "excluded-or-unsafe" continue fi if [ -L "$_entry" ]; then log_skip "$_relpath" "symlink-excluded" continue fi if ! is_under_root "$(resolve_existing_path "$_entry")" "$RESOLVED_LIVE_ROOT"; then log_failure "$_relpath" "outside-live-root" continue fi _stage_path="$INCOMING_ROOT/$_relpath" if [ -d "$_entry" ]; then if [ "$INIT_EXECUTE" -eq 1 ]; then record_live_metadata "$_entry" "$_relpath" "dir" copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "dir" else log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path" fi continue fi if [ "$INIT_EXECUTE" -eq 1 ]; then record_live_metadata "$_entry" "$_relpath" "file" copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "file" else log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path" fi done < "$_entry_list" rm -f "$_entry_list" if [ "$INIT_EXECUTE" -eq 1 ]; then mkdir -p "$_stage_scope_root" chown "$STAGE_USER" "$_stage_scope_root" chmod 0755 "$_stage_scope_root" fi } run_init_sync() { if [ "$INIT_EXECUTE" -eq 1 ]; then require_root fi RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT") if [ "$INIT_EXECUTE" -eq 1 ]; then mkdir -p "$INCOMING_ROOT" db_backup_existing : > "$PERMS_DB" fi run_for_selected_scopes run_init_scope } apply_metadata_to_temp() { if ! user_exists "$2"; then fail "owner '$2' from permissions database does not exist" fi if ! group_exists "$3"; then fail "group '$3' from permissions database does not exist" fi chown "$2:$3" "$1" chmod "$4" "$1" } promote_staged_file() { _stage_file=$1 _relpath=$2 _dest_file=$3 _resolved_dest=$(resolve_existing_path "$_dest_file") if ! is_under_root "$_resolved_dest" "$RESOLVED_LIVE_ROOT"; then log_failure "$_relpath" "outside-live-root" return fi if [ ! -f "$PERMS_DB" ]; then log_failure "$_relpath" "perms-db-missing" return fi if ! _db_line=$(db_lookup_line "$_relpath"); then log_skip "$_relpath" "destination-missing-perms" return fi _owner=$(db_extract_field "$_db_line" "owner") _group=$(db_extract_field "$_db_line" "group") _mode=$(db_extract_field "$_db_line" "mode") if [ "$PROMOTE_EXECUTE" -eq 0 ]; then log_line "$(timestamp_utc) DRY-RUN $_relpath -> $_dest_file owner=$_owner group=$_group mode=$_mode" return fi _tmp_file=$(mktemp "$(dirname -- "$_dest_file")/.promote-sync.XXXXXX") cp "$_stage_file" "$_tmp_file" apply_metadata_to_temp "$_tmp_file" "$_owner" "$_group" "$_mode" mv -f "$_tmp_file" "$_dest_file" log_update "APPLY" "$_relpath" "$_dest_file" } run_promote_scope() { _scope=$1 _stage_scope_root="$INCOMING_ROOT/$_scope" if [ ! -d "$_stage_scope_root" ]; then log_skip "$_scope" "staging-scope-missing" return fi _entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.promote.XXXXXX") find "$_stage_scope_root" \( -type f -o -type l \) > "$_entry_list" while IFS= read -r _entry || [ -n "$_entry" ]; do _relpath=${_entry#"$INCOMING_ROOT/"} if ! validate_path_policy "$_relpath"; then log_skip "$_relpath" "excluded-or-unsafe" continue fi if [ -L "$_entry" ]; then log_skip "$_relpath" "symlink-excluded" continue fi _dest_file="$LIVE_ROOT/$_relpath" if [ ! -e "$_dest_file" ]; then log_skip "$_relpath" "destination-missing" continue fi if ! ensure_parent_directory_exists "$_dest_file"; then log_skip "$_relpath" "destination-parent-missing" continue fi promote_staged_file "$_entry" "$_relpath" "$_dest_file" done < "$_entry_list" rm -f "$_entry_list" } run_promote() { if [ "$PROMOTE_EXECUTE" -eq 1 ]; then require_root fi RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT") run_for_selected_scopes run_promote_scope } print_summary() { log_line "$(timestamp_utc) SUMMARY updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT mode=${MODE:-unknown}" } parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --dry-run) [ -z "$MODE" ] || fail "choose only one primary mode" MODE=promote PROMOTE_EXECUTE=0 ;; --apply) [ -z "$MODE" ] || fail "choose only one primary mode" MODE=promote PROMOTE_EXECUTE=1 ;; --init-sync) [ -z "$MODE" ] || fail "choose only one primary mode" MODE=init ;; --apply-init) INIT_EXECUTE=1 ;; --only) shift [ "$#" -gt 0 ] || fail "--only requires a scope" ONLY_SCOPE=$1 is_allowed_scope "$ONLY_SCOPE" || fail "unsupported scope '$ONLY_SCOPE'" ;; --perms-db) shift [ "$#" -gt 0 ] || fail "--perms-db requires a path" PERMS_DB=$1 ;; --log) shift [ "$#" -gt 0 ] || fail "--log requires a path" LOG_FILE=$1 ;; --stage-root) shift [ "$#" -gt 0 ] || fail "--stage-root requires a path" STAGE_ROOT=$1 INCOMING_ROOT=$STAGE_ROOT/incoming ;; --live-root) shift [ "$#" -gt 0 ] || fail "--live-root requires a path" LIVE_ROOT=$1 ;; --stage-user) shift [ "$#" -gt 0 ] || fail "--stage-user requires a user" STAGE_USER=$1 ;; --help|-h) usage exit 0 ;; *) fail "unknown argument: $1" ;; esac shift done } main() { parse_args "$@" [ -n "$MODE" ] || fail "choose one of --dry-run, --apply, or --init-sync" if [ "$INIT_EXECUTE" -eq 1 ] && [ "$MODE" != "init" ]; then fail "--apply-init requires --init-sync" fi if [ "$MODE" = "init" ] && [ "$INIT_EXECUTE" -eq 0 ]; then log_line "$(timestamp_utc) INFO init-sync-preview only; add --apply-init to write staging files and permissions db" fi case "$MODE" in init) run_init_sync ;; promote) run_promote ;; *) fail "unsupported mode '$MODE'" ;; esac print_summary } main "$@"