#!/bin/sh set -eu PRODUCT="nodepad" BUILDER_IDENTITY="https://nodepad.tools/releases/builder" RELEASE_MIRROR="https://github.com/Nodepad-tools/cli-releases" COMMAND_PRIMARY="nodepad" COMMAND_ALIAS="np" VERSION="${NODEPAD_VERSION:-latest}" BIN_DIR="${NODEPAD_BIN_DIR:-${HOME}/.local/bin}" ALLOW_DOWNGRADE="${NODEPAD_ALLOW_DOWNGRADE:-0}" REPLACE_UNMANAGED="${NODEPAD_REPLACE_UNMANAGED:-0}" MARKER_NAME=".nodepad.install-owner" RELEASE_ROOT="$RELEASE_MIRROR" DETECTED_OS="" DETECTED_ARCH="" download_dir="" new_primary="" new_alias="" new_marker="" backup_dir="" lock_dir="" had_primary=0 had_alias=0 had_marker=0 transaction_active=0 transaction_committed=0 transaction_finalizing=0 error() { printf '%s\n' "Nodepad installer error: $1" >&2 exit 1 } info() { printf '%s\n' "==> $1" } forbidden_identity=$(printf '%s%s' 'invent' 'list') preserves_product_boundary() { normalized=$(printf '%s\n' "$1" | tr '[:upper:]' '[:lower:]') case "$normalized" in *"$forbidden_identity"*) return 1 ;; *) return 0 ;; esac } cleanup() { status=$? rollback_succeeded=1 trap - EXIT HUP INT TERM if [ "$transaction_active" -eq 1 ] && [ "$transaction_committed" -eq 0 ]; then rollback_transaction || { status=1; rollback_succeeded=0; } fi [ -n "$new_primary" ] && rm -f "$new_primary" [ -n "$new_alias" ] && rm -f "$new_alias" [ -n "$new_marker" ] && rm -f "$new_marker" if [ "$rollback_succeeded" -eq 1 ] && [ "$transaction_finalizing" -eq 0 ]; then if [ -n "$backup_dir" ] && ! rmdir "$backup_dir" 2>/dev/null; then rollback_succeeded=0 status=1 fi [ "$rollback_succeeded" -eq 0 ] || { [ -z "$lock_dir" ] || rm -rf "$lock_dir"; } fi [ -n "$download_dir" ] && rm -rf "$download_dir" exit "$status" } trap cleanup EXIT trap 'exit 1' HUP INT TERM need() { command -v "$1" >/dev/null 2>&1 || error "required tool is unavailable: $1" } need curl need tar need openssl need mktemp need uname need id need stat need readlink need gzip need od need cat need awk need grep need wc need tr need cmp need chmod need mkdir need mv need cp need rm need rmdir [ -n "$DETECTED_OS" ] || DETECTED_OS=$(uname -s) [ -n "$DETECTED_ARCH" ] || DETECTED_ARCH=$(uname -m) CURRENT_UID=$(id -u) if command -v sha256sum >/dev/null 2>&1; then sha256_file() { sha256sum "$1" | awk '{print $1}'; } elif command -v shasum >/dev/null 2>&1; then sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; } else error "required SHA-256 tool is unavailable" fi case "$VERSION" in latest) ;; v[0-9]*.[0-9]*.[0-9]*) ;; *) error "NODEPAD_VERSION must be latest or a v-prefixed semantic version" ;; esac case "$DETECTED_OS" in Darwin) platform_os="Darwin" ;; Linux) platform_os="Linux" ;; *) error "unsupported operating system: $DETECTED_OS" ;; esac case "$DETECTED_ARCH" in arm64|aarch64) platform_arch="arm64" ;; x86_64|amd64) platform_arch="x86_64" ;; *) error "unsupported architecture: $DETECTED_ARCH" ;; esac asset="${PRODUCT}_${platform_os}_${platform_arch}.tar.gz" if [ "$VERSION" = "latest" ]; then release_url="$RELEASE_ROOT/releases/latest/download" else release_url="$RELEASE_ROOT/releases/download/$VERSION" fi fetch() { url=$1 destination=$2 optional=${3:-0} if ! effective_url=$(curl --proto '=https' --proto-redir '=https' --tlsv1.2 --location -fsSo "$destination" -w '%{url_effective}' "$url"); then rm -f "$destination" [ "$optional" -eq 1 ] && return 1 error "download failed" fi case "$effective_url" in https://github.com/Nodepad-tools/cli-releases/*|https://release-assets.githubusercontent.com/*|https://objects.githubusercontent.com/*) ;; *) error "download crossed the Nodepad release boundary" ;; esac } download_dir=$(mktemp -d "${TMPDIR:-/tmp}/nodepad-installer.XXXXXX") || error "could not create a private temporary directory" chmod 700 "$download_dir" mkdir "$download_dir/stage" info "downloading signed Nodepad release metadata" fetch "$release_url/provenance.json" "$download_dir/provenance.json" fetch "$release_url/checksums.txt" "$download_dir/checksums.txt" fetch "$release_url/$asset" "$download_dir/$asset" valid_signatures=0 cat >"$download_dir/key.4e18da7a67cca581.pem" <<'NODEPAD_PUBLIC_KEY_4e18da7a67cca581' -----BEGIN PUBLIC KEY----- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArd/wGwwkIVqIG7iqObTL BaTZN2NqaJxsMhzUVHjsHG9xT3m41g3q+Rg2p8KUarzpR3w8wFnGvZSXXn1w5EYv 6nGeNHlxjzIXGKpGtM6OtNMfOP3GweAtQ2kuLSU8qSXWIoA1DcO+yhUINRkAM8zR cTZZjlLWGbPj2KR2jOAQoHPuCiKP+UGCWM6CT63Mkig12g/WIWcXSmYoOymIER3o s9rKZjDecFAEfws5WmeyqZsQIMtb8/UvcXPFpZ4OPxTLJGCmGqcV0FgHXBXm1xfZ oJXbw3Kml21sFbbmY7hcQjjxNPyFvepugUFer8YyYPIR14qW2XhmSgXLAmZF5jrH C8lij4CWVQGj7WYM6pQFFw7iZ1LfFkhFIL6/Fnaq9WjcJ0caI817w2DMXKEUNB1+ l2BdVw1MU3CHQ8nJnhPi86ARi94Q2PBvCtVaEIiG/874cM/ybeR65MLEM1p3G837 b1RpOychPECKZ+u9khj+iRWMmBLoC3mlG1xBFTXTETcTAgMBAAE= -----END PUBLIC KEY----- NODEPAD_PUBLIC_KEY_4e18da7a67cca581 if fetch "$release_url/provenance.4e18da7a67cca581.sig" "$download_dir/provenance.4e18da7a67cca581.sig" 1; then if openssl dgst -sha256 -verify "$download_dir/key.4e18da7a67cca581.pem" \ -signature "$download_dir/provenance.4e18da7a67cca581.sig" \ "$download_dir/provenance.json" >/dev/null 2>&1; then valid_signatures=$((valid_signatures + 1)) fi fi [ "$valid_signatures" -ge 1 ] || error "no trusted provenance signature validated" if ! metadata=$(LC_ALL=C awk \ -v expected_product="$PRODUCT" -v requested_version="$VERSION" \ -v selected_asset="$asset" -v expected_builder="$BUILDER_IDENTITY" ' function reject() { invalid = 1; exit 1 } function take(literal) { if (substr(rest, 1, length(literal)) != literal) reject() rest = substr(rest, length(literal) + 1) } function take_string(marker, position, value) { position = index(rest, marker) if (position < 1) reject() value = substr(rest, 1, position - 1) rest = substr(rest, position + length(marker)) return value } function digits(value) { return value != "" && value ~ /^[0-9]+$/ } function canonical_number(value) { return digits(value) && (value == "0" || substr(value, 1, 1) != "0") } function identifiers_valid(value, numeric_leading_zero_allowed, count, parts, i) { if (value == "") return 0 count = split(value, parts, /[.]/) for (i = 1; i <= count; i += 1) { if (parts[i] == "" || parts[i] !~ /^[0-9A-Za-z-]+$/) return 0 if (!numeric_leading_zero_allowed && digits(parts[i]) && !canonical_number(parts[i])) return 0 } return 1 } function semver_valid(value, body, build_at, pre_at, core, count, parts) { if (substr(value, 1, 1) != "v") return 0 body = substr(value, 2) build_at = index(body, "+") if (build_at) { if (!identifiers_valid(substr(body, build_at + 1), 1)) return 0 body = substr(body, 1, build_at - 1) if (index(body, "+")) return 0 } pre_at = index(body, "-") if (pre_at) { if (!identifiers_valid(substr(body, pre_at + 1), 0)) return 0 body = substr(body, 1, pre_at - 1) } count = split(body, parts, /[.]/) return count == 3 && canonical_number(parts[1]) && canonical_number(parts[2]) && canonical_number(parts[3]) } NR != 1 { reject() } { if (index($0, "\r")) reject() rest = $0 take("{\"schema_version\":1,\"product\":\"" expected_product "\",\"version\":\"") version = take_string("\",\"source_revision\":\"") revision = take_string("\",\"source_commit_epoch\":") epoch = take_string(",\"builder_identity\":\"") builder = take_string("\",\"subjects\":[") if (length(rest) < 3 || substr(rest, length(rest) - 1) != "]}") reject() subjects = substr(rest, 1, length(rest) - 2) rest = "" if (!semver_valid(version)) reject() if (requested_version != "latest" && version != requested_version) reject() if (length(revision) != 64 || revision !~ /^[0-9a-f]+$/) reject() if (!canonical_number(epoch)) reject() if (builder != expected_builder) reject() if (subjects == "") reject() subject_count = split(subjects, entries, /\},\{/) previous = "" selected_count = checksums_count = 0 for (i = 1; i <= subject_count; i += 1) { subject = entries[i] if (i > 1) subject = "{" subject if (i < subject_count) subject = subject "}" prefix = "{\"name\":\"" if (substr(subject, 1, length(prefix)) != prefix) reject() subject = substr(subject, length(prefix) + 1) position = index(subject, "\",\"sha256\":\"") if (!position) reject() name = substr(subject, 1, position - 1) subject = substr(subject, position + length("\",\"sha256\":\"")) position = index(subject, "\",\"size\":") if (!position) reject() digest = substr(subject, 1, position - 1) size = substr(subject, position + length("\",\"size\":")) if (substr(size, length(size), 1) != "}") reject() size = substr(size, 1, length(size) - 1) if (name == "" || name !~ /^[0-9A-Za-z_.-]+$/) reject() if (previous != "" && !(previous < name)) reject() if (length(digest) != 64 || digest !~ /^[0-9a-f]+$/) reject() if (!canonical_number(size)) reject() previous = name if (name == selected_asset) { selected_count += 1; archive_digest = digest; archive_size = size } if (name == "checksums.txt") { checksums_count += 1; checksums_digest = digest; checksums_size = size } } if (selected_count != 1 || checksums_count != 1) reject() print version, archive_digest, archive_size, checksums_digest, checksums_size } END { if (invalid || NR != 1) exit 1 } ' "$download_dir/provenance.json"); then error "signed provenance is invalid" fi IFS=' ' read -r release_version provenance_archive_digest provenance_archive_size \ provenance_checksums_digest provenance_checksums_size metadata_extra <"$download_dir/archive.tar" || error "archive compression is invalid" od -An -v -tu1 "$download_dir/archive.tar" >"$download_dir/archive.bytes" || error "archive headers could not be inspected" LC_ALL=C awk -v primary="$COMMAND_PRIMARY" -v alias_name="$COMMAND_ALIAS" ' function reject() { invalid = 1; exit 1 } function block_is_zero( i) { for (i = 1; i <= 512; i += 1) if (block[i] != 0) return 0 return 1 } function text(first, last, value, i) { value = "" for (i = first; i <= last && block[i] != 0; i += 1) { if (block[i] < 32 || block[i] > 126) reject() value = value sprintf("%c", block[i]) } return value } function octal(first, last, value, seen, i, byte) { value = 0; seen = 0 for (i = first; i <= last; i += 1) { byte = block[i] if (byte == 0 || byte == 32) continue if (byte < 48 || byte > 55) reject() seen = 1; value = (value * 8) + byte - 48 } if (!seen) reject() return value } function inspect_header( name, prefix, type, size, expected_checksum, actual_checksum, i) { if (block_is_zero()) { ended = 1; return } if (ended) reject() expected_checksum = octal(149, 156) actual_checksum = 0 for (i = 1; i <= 512; i += 1) actual_checksum += (i >= 149 && i <= 156) ? 32 : block[i] if (actual_checksum != expected_checksum) reject() type = block[157] if (type != 0 && type != 48) reject() prefix = text(346, 500) name = text(1, 100) if (prefix != "") name = prefix "/" name size = octal(125, 136) entries += 1 if (name == primary) primary_count += 1 else if (name == alias_name) alias_count += 1 else reject() data_blocks = int((size + 511) / 512) } { for (field = 1; field <= NF; field += 1) { byte = $field if (byte !~ /^[0-9]+$/ || byte < 0 || byte > 255) reject() block[++offset] = byte if (offset == 512) { if (data_blocks > 0) data_blocks -= 1 else inspect_header() delete block offset = 0 } } } END { if (invalid || offset != 0 || data_blocks != 0 || entries != 2 || primary_count != 1 || alias_count != 1 || !ended) exit 1 } ' "$download_dir/archive.bytes" || error "archive contains unsafe or unexpected headers" tar -xzf "$download_dir/$asset" -C "$download_dir/stage" "$COMMAND_PRIMARY" "$COMMAND_ALIAS" || error "archive extraction failed" for command_name in "$COMMAND_PRIMARY" "$COMMAND_ALIAS"; do staged="$download_dir/stage/$command_name" [ -f "$staged" ] && [ ! -L "$staged" ] || error "archive did not contain a regular $command_name executable" chmod 755 "$staged" done compare_semver() { LC_ALL=C awk -v left="$1" -v right="$2" ' function digits(value) { return value != "" && value ~ /^[0-9]+$/ } function canonical_number(value) { return digits(value) && (value == "0" || substr(value, 1, 1) != "0") } function identifiers_valid(value, allow_zeroes, count, parts, i) { if (value == "") return 0 count = split(value, parts, /[.]/) for (i = 1; i <= count; i += 1) { if (parts[i] == "" || parts[i] !~ /^[0-9A-Za-z-]+$/) return 0 if (!allow_zeroes && digits(parts[i]) && !canonical_number(parts[i])) return 0 } return 1 } function parse(value, side, body, position, build, pre, count, core, i) { if (substr(value, 1, 1) != "v") return 0 body = substr(value, 2) position = index(body, "+") if (position) { build = substr(body, position + 1) body = substr(body, 1, position - 1) if (!identifiers_valid(build, 1) || index(body, "+")) return 0 } position = index(body, "-") if (position) { pre = substr(body, position + 1) body = substr(body, 1, position - 1) if (!identifiers_valid(pre, 0)) return 0 } count = split(body, core, /[.]/) if (count != 3 || !canonical_number(core[1]) || !canonical_number(core[2]) || !canonical_number(core[3])) return 0 if (side == "left") { l1 = core[1]; l2 = core[2]; l3 = core[3]; l_has_pre = pre != ""; l_count = split(pre, l_pre, /[.]/) } else { r1 = core[1]; r2 = core[2]; r3 = core[3]; r_has_pre = pre != ""; r_count = split(pre, r_pre, /[.]/) } return 1 } function number_compare(a, b) { if (length(a) != length(b)) return length(a) < length(b) ? -1 : 1 if (a == b) return 0 return ("x" a) < ("x" b) ? -1 : 1 } function identifier_compare(a, b, a_numeric, b_numeric) { if (a == b) return 0 a_numeric = digits(a); b_numeric = digits(b) if (a_numeric && b_numeric) return number_compare(a, b) if (a_numeric != b_numeric) return a_numeric ? -1 : 1 return ("x" a) < ("x" b) ? -1 : 1 } BEGIN { if (!parse(left, "left") || !parse(right, "right")) exit 2 result = number_compare(l1, r1) if (!result) result = number_compare(l2, r2) if (!result) result = number_compare(l3, r3) if (result) { print result; exit } if (!l_has_pre || !r_has_pre) { print (l_has_pre == r_has_pre ? 0 : (l_has_pre ? -1 : 1)) exit } limit = l_count < r_count ? l_count : r_count for (i = 1; i <= limit; i += 1) { result = identifier_compare(l_pre[i], r_pre[i]) if (result) { print result; exit } } print (l_count == r_count ? 0 : (l_count < r_count ? -1 : 1)) }' "$version_capture" 2>&1 || error "staged command failed its version check" version_output=$(LC_ALL=C awk 'NR == 1 { value = $0 } END { if (NR != 1) exit 1; print value }' "$version_capture") || error "staged command returned malformed version output" help_output=$("$candidate" --help 2>&1) || error "staged command failed its help check" preserves_product_boundary "$version_output" || error "staged command has mixed product branding" preserves_product_boundary "$help_output" || error "staged command has mixed product branding" case "$version_output" in "$PRODUCT "*) parsed=${version_output#"$PRODUCT "} ;; *) error "staged command has the wrong version branding" ;; esac compare_semver "$parsed" "$parsed" >/dev/null || error "staged command returned no semantic version" printf '%s\n' "$PRODUCT $parsed" >"$expected_capture" cmp -s "$version_capture" "$expected_capture" || error "staged command returned malformed version output" printf '%s\n' "$help_output" | grep -F "$PRODUCT" >/dev/null || error "staged command has the wrong help branding" printf '%s\n' "$parsed" } primary_version=$(smoke_version "$download_dir/stage/$COMMAND_PRIMARY") alias_version=$(smoke_version "$download_dir/stage/$COMMAND_ALIAS") [ "$primary_version" = "$alias_version" ] || error "staged command versions differ" [ "$primary_version" = "$release_version" ] || error "staged command version differs from signed provenance" destination_exists() { [ -e "$1" ] || [ -L "$1" ] } path_metadata() { if [ "$DETECTED_OS" = "Darwin" ]; then stat -f '%u %Lp' "$1" else stat -c '%u %a' "$1" fi } owned_mode() { path=$1 expected_mode=$2 [ ! -L "$path" ] && [ -f "$path" ] || return 1 metadata=$(path_metadata "$path") || return 1 IFS=' ' read -r path_uid path_mode path_extra < 8 { exit 1 } END { if (NR != 8 || version == "" || digest == "" || primary_digest == "" || alias_digest == "") exit 1 print version, digest, primary_digest, alias_digest } ' "$marker" } owned_digest() { path=$1 expected_mode=$2 expected_digest=$3 owned_mode "$path" "$expected_mode" || return 1 [ "$(sha256_file "$path")" = "$expected_digest" ] } owned_restore_digest() { path=$1 expected_mode=$2 expected_digest=$3 expected_kind=$4 if [ "$expected_kind" = symlink ]; then owned_historical_alias "$path" || return 1 if [ "$(sha256_file "$path" 2>/dev/null || printf '%s' missing)" != "$expected_digest" ]; then owned_digest "$primary_destination" 755 "$expected_digest" || return 1 fi else owned_mode "$path" "$expected_mode" || return 1 [ "$(sha256_file "$path")" = "$expected_digest" ] || return 1 fi return 0 } write_lock_value() { name=$1 value=$2 temporary="$lock_dir/.$name.tmp" printf '%s\n' "$value" >"$temporary" chmod 600 "$temporary" mv "$temporary" "$lock_dir/$name" } validate_lock_value() { file=$1 pattern=$2 owned_mode "$file" 600 || return 1 value=$(LC_ALL=C awk 'NR == 1 { value = $0 } END { if (NR != 1) exit 1; print value }' "$file") || return 1 printf '%s\n' "$value" | grep -E "$pattern" >/dev/null 2>&1 || return 1 printf '%s\n' "$value" } validate_restore_path() { restore_had=$1 restore_backup=$2 restore_destination=$3 restore_mode=$4 restore_digest=$5 restore_kind=$6 if [ "$restore_had" -eq 1 ]; then if destination_exists "$restore_backup"; then owned_restore_digest "$restore_backup" "$restore_mode" "$restore_digest" "$restore_kind" else owned_restore_digest "$restore_destination" "$restore_mode" "$restore_digest" "$restore_kind" fi else if destination_exists "$restore_backup"; then return 1; fi fi } restore_path() { restore_had=$1 restore_backup=$2 restore_destination=$3 restore_mode=$4 restore_digest=$5 restore_kind=$6 if [ "$restore_had" -eq 1 ]; then if destination_exists "$restore_backup"; then rm -f "$restore_destination" mv "$restore_backup" "$restore_destination" fi owned_restore_digest "$restore_destination" "$restore_mode" "$restore_digest" "$restore_kind" else rm -f "$restore_destination" fi } recover_stale_lock() { candidate=$1 [ ! -L "$candidate" ] && [ -d "$candidate" ] || return 1 metadata=$(path_metadata "$candidate") || return 1 IFS=' ' read -r path_uid path_mode path_extra </dev/null && return 1 for stale_entry in "$candidate"/* "$candidate"/.[!.]*; do destination_exists "$stale_entry" || continue case "${stale_entry##*/}" in pid|uid|state) ;; backup|had_primary|had_alias|had_marker|new_primary|new_alias|new_marker|old_primary_sha256|old_alias_sha256|old_marker_sha256|old_alias_kind|installed_primary_sha256|installed_alias_sha256|installed_marker_sha256) [ "$stale_state" != prepared ] || return 1 ;; *) return 1 ;; esac done if [ "$stale_state" != prepared ]; then stale_backup=$(validate_lock_value "$candidate/backup" '^[.A-Za-z0-9_-]+$') || return 1 stale_had_primary=$(validate_lock_value "$candidate/had_primary" '^[01]$') || return 1 stale_had_alias=$(validate_lock_value "$candidate/had_alias" '^[01]$') || return 1 stale_had_marker=$(validate_lock_value "$candidate/had_marker" '^[01]$') || return 1 stale_new_primary=$(validate_lock_value "$candidate/new_primary" '^[.A-Za-z0-9_-]+$') || return 1 stale_new_alias=$(validate_lock_value "$candidate/new_alias" '^[.A-Za-z0-9_-]+$') || return 1 stale_new_marker=$(validate_lock_value "$candidate/new_marker" '^[.A-Za-z0-9_-]+$') || return 1 stale_old_primary_digest=$(validate_lock_value "$candidate/old_primary_sha256" '^([0-9a-f]{64}|none)$') || return 1 stale_old_alias_digest=$(validate_lock_value "$candidate/old_alias_sha256" '^([0-9a-f]{64}|none)$') || return 1 stale_old_marker_digest=$(validate_lock_value "$candidate/old_marker_sha256" '^([0-9a-f]{64}|none)$') || return 1 stale_old_alias_kind=$(validate_lock_value "$candidate/old_alias_kind" '^(file|symlink|none)$') || return 1 stale_installed_primary_digest=$(validate_lock_value "$candidate/installed_primary_sha256" '^[0-9a-f]{64}$') || return 1 stale_installed_alias_digest=$(validate_lock_value "$candidate/installed_alias_sha256" '^[0-9a-f]{64}$') || return 1 stale_installed_marker_digest=$(validate_lock_value "$candidate/installed_marker_sha256" '^[0-9a-f]{64}$') || return 1 case "$stale_backup" in .nodepad.backup.*) ;; *) return 1 ;; esac case "$stale_new_primary" in .nodepad.new-primary.*) ;; *) return 1 ;; esac case "$stale_new_alias" in .nodepad.new-alias.*) ;; *) return 1 ;; esac case "$stale_new_marker" in .nodepad.new-marker.*) ;; *) return 1 ;; esac stale_backup_dir="$BIN_DIR/$stale_backup" if destination_exists "$stale_backup_dir"; then [ ! -L "$stale_backup_dir" ] && [ -d "$stale_backup_dir" ] || return 1 [ "$(path_metadata "$stale_backup_dir")" = "$CURRENT_UID 700" ] || return 1 for stale_entry in "$stale_backup_dir"/* "$stale_backup_dir"/.[!.]*; do destination_exists "$stale_entry" || continue case "${stale_entry##*/}" in "$COMMAND_PRIMARY"|"$COMMAND_ALIAS"|"$MARKER_NAME") ;; *) return 1 ;; esac done fi if [ "$stale_state" = finalizing ] || [ "$stale_state" = committed ]; then stale_marker_data=$(read_owner_marker "$marker_destination") || return 1 IFS=' ' read -r _stale_version _stale_archive stale_primary_digest stale_alias_digest stale_extra </dev/null || return 1 fi rm -f "$BIN_DIR/$stale_new_primary" "$BIN_DIR/$stale_new_alias" "$BIN_DIR/$stale_new_marker" fi rm -rf "$candidate" } maybe_fail() { return 0 } rollback_maybe_fail() { return 0 } rollback_transaction() { rollback_maybe_fail before_marker_rollback || return 1 restore_path "$had_marker" "$backup_dir/$MARKER_NAME" "$marker_destination" 600 "$old_marker_digest" file || return 1 rollback_maybe_fail after_marker_rollback || return 1 rollback_maybe_fail before_primary_rollback || return 1 restore_path "$had_primary" "$backup_dir/$COMMAND_PRIMARY" "$primary_destination" 755 "$old_primary_digest" file || return 1 rollback_maybe_fail after_primary_rollback || return 1 rollback_maybe_fail before_alias_rollback || return 1 restore_path "$had_alias" "$backup_dir/$COMMAND_ALIAS" "$alias_destination" 755 "$old_alias_digest" "$old_alias_kind" || return 1 rollback_maybe_fail after_alias_rollback || return 1 } current_version="" primary_destination="$BIN_DIR/$COMMAND_PRIMARY" alias_destination="$BIN_DIR/$COMMAND_ALIAS" marker_destination="$BIN_DIR/$MARKER_NAME" [ ! -L "$BIN_DIR" ] || error "refusing an unsafe binary directory" mkdir -p "$BIN_DIR" secure_bin_directory || error "binary directory must be owned by the current user and not group/world writable" lock_candidate="$BIN_DIR/.nodepad.install.lock" if ! mkdir "$lock_candidate" 2>/dev/null; then recover_stale_lock "$lock_candidate" || error "another Nodepad installation is in progress or its lock is unsafe" mkdir "$lock_candidate" 2>/dev/null || error "could not acquire the Nodepad installation lock" fi lock_dir=$lock_candidate chmod 700 "$lock_dir" write_lock_value pid "$$" write_lock_value uid "$CURRENT_UID" write_lock_value state prepared if destination_exists "$marker_destination"; then owned_mode "$marker_destination" 600 || error "installed ownership marker has unsafe type, owner, or mode" marker_valid=0 marker_data=$(read_owner_marker "$marker_destination" 2>/dev/null) && marker_valid=1 if [ "$marker_valid" -eq 1 ]; then IFS=' ' read -r current_version _current_archive_digest current_primary_digest current_alias_digest marker_extra </dev/null || marker_valid=0 owned_digest "$primary_destination" 755 "$current_primary_digest" || marker_valid=0 if [ -L "$alias_destination" ]; then owned_historical_alias "$alias_destination" && [ "$(sha256_file "$alias_destination")" = "$current_alias_digest" ] || marker_valid=0 else owned_digest "$alias_destination" 755 "$current_alias_digest" || marker_valid=0 fi fi if [ "$marker_valid" -ne 1 ]; then [ "$REPLACE_UNMANAGED" = 1 ] || error "installed commands do not match their ownership marker; set NODEPAD_REPLACE_UNMANAGED=1 to replace them as unmanaged" [ ! -L "$alias_destination" ] || error "an unverified historical alias cannot be replaced as unmanaged" if ! destination_exists "$primary_destination" || ! destination_exists "$alias_destination"; then error "refusing an incomplete unmanaged command pair" fi if ! owned_mode "$primary_destination" 755 || ! owned_mode "$alias_destination" 755; then error "unmanaged command pair is unsafe" fi current_version="" fi had_primary=1 had_alias=1 had_marker=1 elif destination_exists "$primary_destination" || destination_exists "$alias_destination"; then [ "$REPLACE_UNMANAGED" = 1 ] || error "unmanaged commands are not executed; set NODEPAD_REPLACE_UNMANAGED=1 to replace verified local files" if ! destination_exists "$primary_destination" || ! destination_exists "$alias_destination"; then error "refusing an incomplete unmanaged command pair" fi if ! owned_mode "$primary_destination" 755 || ! owned_mode "$alias_destination" 755; then error "unmanaged command pair is unsafe" fi had_primary=1 had_alias=1 fi if [ -n "$current_version" ]; then version_comparison=$(compare_semver "$primary_version" "$current_version") || error "installed command returned an invalid semantic version" [ "$version_comparison" != "-1" ] || [ "$ALLOW_DOWNGRADE" = "1" ] || error "downgrade refused; set NODEPAD_ALLOW_DOWNGRADE=1 to allow it" fi new_primary=$(mktemp "$BIN_DIR/.nodepad.new-primary.XXXXXX") || error "could not stage the primary command" new_alias=$(mktemp "$BIN_DIR/.nodepad.new-alias.XXXXXX") || error "could not stage the alias command" new_marker=$(mktemp "$BIN_DIR/.nodepad.new-marker.XXXXXX") || error "could not stage the ownership marker" cp "$download_dir/stage/$COMMAND_PRIMARY" "$new_primary" cp "$download_dir/stage/$COMMAND_ALIAS" "$new_alias" chmod 755 "$new_primary" "$new_alias" new_primary_digest=$(sha256_file "$new_primary") new_alias_digest=$(sha256_file "$new_alias") printf '%s\n' 'schema=2' "product=$PRODUCT" "version=$primary_version" \ "archive_sha256=$actual_archive_digest" "primary=$COMMAND_PRIMARY" "primary_sha256=$new_primary_digest" \ "alias=$COMMAND_ALIAS" "alias_sha256=$new_alias_digest" >"$new_marker" chmod 600 "$new_marker" cmp -s "$download_dir/stage/$COMMAND_PRIMARY" "$new_primary" || error "primary staging copy failed" cmp -s "$download_dir/stage/$COMMAND_ALIAS" "$new_alias" || error "alias staging copy failed" read_owner_marker "$new_marker" >/dev/null || error "ownership marker staging failed" backup_dir=$(mktemp -d "$BIN_DIR/.nodepad.backup.XXXXXX") || error "could not reserve an exclusive backup directory" chmod 700 "$backup_dir" secure_backup_metadata=$(path_metadata "$backup_dir") || error "backup reservation is unsafe" [ "$secure_backup_metadata" = "$CURRENT_UID 700" ] || error "backup reservation is unsafe" old_primary_digest=none old_alias_digest=none old_marker_digest=none old_alias_kind=none [ "$had_primary" -eq 0 ] || old_primary_digest=$(sha256_file "$primary_destination") if [ "$had_alias" -eq 1 ]; then old_alias_digest=$(sha256_file "$alias_destination") if [ -L "$alias_destination" ]; then old_alias_kind="symlink"; else old_alias_kind="file"; fi fi [ "$had_marker" -eq 0 ] || old_marker_digest=$(sha256_file "$marker_destination") transaction_active=1 write_lock_value backup "${backup_dir##*/}" write_lock_value had_primary "$had_primary" write_lock_value had_alias "$had_alias" write_lock_value had_marker "$had_marker" write_lock_value new_primary "${new_primary##*/}" write_lock_value new_alias "${new_alias##*/}" write_lock_value new_marker "${new_marker##*/}" write_lock_value old_primary_sha256 "$old_primary_digest" write_lock_value old_alias_sha256 "$old_alias_digest" write_lock_value old_marker_sha256 "$old_marker_digest" write_lock_value old_alias_kind "$old_alias_kind" write_lock_value installed_primary_sha256 "$new_primary_digest" write_lock_value installed_alias_sha256 "$new_alias_digest" write_lock_value installed_marker_sha256 "$(sha256_file "$new_marker")" write_lock_value state backing_up if [ "$had_primary" -eq 1 ]; then maybe_fail before_primary_backup mv "$primary_destination" "$backup_dir/$COMMAND_PRIMARY" owned_mode "$backup_dir/$COMMAND_PRIMARY" 755 || error "primary command changed during backup" maybe_fail after_primary_backup fi if [ "$had_alias" -eq 1 ]; then maybe_fail before_alias_backup mv "$alias_destination" "$backup_dir/$COMMAND_ALIAS" if [ -L "$backup_dir/$COMMAND_ALIAS" ]; then owned_historical_alias "$backup_dir/$COMMAND_ALIAS" || error "historical alias changed during backup" else owned_mode "$backup_dir/$COMMAND_ALIAS" 755 || error "alias command changed during backup" fi maybe_fail after_alias_backup fi if [ "$had_marker" -eq 1 ]; then maybe_fail before_marker_backup mv "$marker_destination" "$backup_dir/$MARKER_NAME" owned_mode "$backup_dir/$MARKER_NAME" 600 || error "ownership marker changed during backup" maybe_fail after_marker_backup fi write_lock_value state installing maybe_fail before_primary_rename mv "$new_primary" "$primary_destination" new_primary="" maybe_fail after_primary_rename maybe_fail before_alias_rename mv "$new_alias" "$alias_destination" new_alias="" maybe_fail after_alias_rename maybe_fail before_marker_rename mv "$new_marker" "$marker_destination" new_marker="" maybe_fail after_marker_rename write_lock_value state installed maybe_fail after_installed_state maybe_fail before_backup_cleanup write_lock_value state finalizing transaction_active=0 transaction_finalizing=1 maybe_fail after_finalizing_state rm -rf "$backup_dir" || error "backup cleanup failed; rolling back installation" backup_dir="" maybe_fail after_backup_deletion write_lock_value state committed transaction_finalizing=0 transaction_committed=1 maybe_fail after_committed_state rm -rf "$lock_dir" || error "installation completed but lock cleanup failed" lock_dir="" info "installed Nodepad $primary_version as $COMMAND_PRIMARY and $COMMAND_ALIAS in $BIN_DIR"