diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9555b1..6d14e9b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,10 +28,10 @@ jobs: python-version: "3.9" - name: Check shell syntax - run: bash -n paperscript.sh tests/live-smoke.sh + run: bash -n paperscript.sh 1MB-minecraft.sh tests/live-smoke.sh - name: Check Python syntax - run: python -m py_compile paperscript/paperscript.py tests/test_paperscript.py + run: python -m py_compile paperscript/paperscript.py tests/test_paperscript.py tests/test_1mb_minecraft.py - name: Run unit tests run: python -m unittest discover -s tests -p "test_*.py" -v diff --git a/.gitignore b/.gitignore index f6def0f..7107890 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ state.json paperscript/backups/ paperscript/downloads/ paperscript/cache/ +paperscript/locks/ paperscript/logs.log paperscript/config.json paperscript/state.json diff --git a/1MB-minecraft.sh b/1MB-minecraft.sh new file mode 100755 index 0000000..5de5192 --- /dev/null +++ b/1MB-minecraft.sh @@ -0,0 +1,596 @@ +#!/usr/bin/env bash + +# @Filename: 1MB-minecraft.sh +# @Version: 2.21.0, build 084 for Minecraft 26.2 (Java 26, 64bit) +# @Release: August 4th, 2026 +# @Description: Helps us start a Paper 26.2 server. +# @PaperUpdate source: /Users/floris/Projects/1MB/Resources/Server/1MB-minecraft.sh +# @PaperUpdate source SHA-256: 860d482ea706526d86f9584816c6851a41577888ce040836e9201845cb1942d1 +# @PaperUpdate test customization: Prefer the greatest numeric Paper build for the configured Minecraft version. +# @Contact: I am @floris on Twitter, and mrfloris in MineCraft. +# @Discord: @mrfloris on https://discord.gg/floris +# @Install: chmod +x 1MB-minecraft.sh +# @Syntax: ./1MB-minecraft.sh +# @URL: Latest source, wiki, & support: https://scripts.1moreblock.com/ + +### CONFIGURATION +# +# Declarations here you can customize to your preferred setup. +# Generally only if you actually have to. Check Wiki for details. +# +### + +_minecraftVersion="26.2" +# Which version are we running? + +_minJavaVersion=26 +# use 26 for java 26.0.2 which can be used with Minecraft 26.2+ +# use 25 for java 25.0.4 which can be used with Minecraft 1.21.11+ +# use 24 for java 24.x which can be used with Minecraft 1.21.8 +# use 23 for java 23.x which can be used with Minecraft 1.21.4+ +# use 21 for java 21.x which can be used with Minecraft 1.19.x+ + +_javaMemory="-Xms4G -Xmx4G" +# "" = uses the default +# "-Xmx2G" = maximum memory allocation pool of memory for JVM. +# "-Xms1G" = initial memory allocation pool of memory for JVM. +# More details here: https://stackoverflow.com/questions/14763079/ +# Example: (10GB host for dedicated Paper 26.1.x server with custom flags, using 10GB ram, etc.) +# _javaMemory="-Xms10G -Xmx10G -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs -Daikars.new.flags=true" +# _javaMemory="-Xms10240M -Xmx10240M -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs -Daikars.new.flags=true -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20" +# Figure out optimal flags for your configuration here: https://flags.sh/ + +# jvm startup parameters +_javaParams="-Dfile.encoding=UTF-8 -Dapple.awt.UIElement=true --sun-misc-unsafe-memory-access=allow -Xlog:gc*:logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M -XX:ErrorFile=logs/hs_err_pid%p.log" +# -Dfile.encoding=UTF-8 (UTF-8 characters will be saved properly in the log files, and should correctly display in the console.) +# -Dapple.awt.UIElement=true (Helps on macOS to not show icon in cmd-tab) +# -Dhttps.protocols=TLSv1 (Temporary fix for older discordsrv, you can ignore this one probably) +# -Dterminal.ansi=false (Temporary fix for older screen sessions that have hex-issues) +# --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED (Temporary fix for asyncworldedit and java16) +# and --add-opens java.desktop/java.awt=ALL-UNNAMED +# and --add-opens java.desktop/java.awt.color=ALL-UNNAMED +# --illegal-access=permit (Temporary fix to get outdated plugins to work on 1.17.1) +# -Dlog4j2.formatMsgNoLookups=true (Temporary fix to help address log4j2 issue for pre 1.18.2 servers) +# -Dpaper.useLegacyPluginLoading=true (Temporary fix circular plugin loading issue) +# --enable-native-access=ALL-UNNAMED (Remove startup warning when using java24) +# --sun-misc-unsafe-memory-access=allow (Remove unsafe warning during java24 jvm startup) + +# Override auto engine jar detection; only use this if you have issues +_engine="Paper" +# spigot until paper jar is out +# "" assumes auto detection for -26.1.x.jar +# "spigot" assumes to look for spigot-26.1.x.jar +# "paper" assumes to look for paper-26.1.x.jar + +_engineParams="" +# Leave empty for every day running, only edit when you need this! +# --forceUpgrade (One time converts world chunks to new engine version) (Note: Do not use Paper's forceUpgrade, it will ruin your worlds) +# --eraseCache (Removes caches. Cached data is used to store the skylight, blocklight and biomes, alongside other stuff) (Note: Do not use Paper's eraseCache, it will ruin your worlds) +# --recreateRegionFiles: Triggers world optimization similar to --forceUpgrade, +# but also rewrites all chunks regardless of whether they have already been upgraded. +# Note: Be sure to adjust the region-file-compression setting before using this option. + + +# By changing the setting below to true you are indicating your agreement to Mojang's EULA +# which is legally binding, and you should read it! https://account.mojang.com/documents/minecraft_eula +_eula=false + +# leave "" if you want the 26.1.x server-gui +_noGui="--nogui" + +### INTERNAL CONFIGURATION +# +# Configuration variables you should probably +# leave alone, but can change if really needed. +# +### + +_javaBin="" +# Leave empty for auto-discovery of java path, and +# if this fails, you could hard code the path, as exampled below: +# _javaBin="/Library/Java/JavaVirtualMachines/jdk-25.0.2.jdk/Contents/Home/bin/java" +# _javaBin="/Library/Java/JavaVirtualMachines/jdk-21.0.1.jdk/Contents/Home/bin/java" + +_debug=true +# Debug mode off or on? Default: false (true means it spits out progress) + +### FUNCTIONS AND CODE +# +# ! WE ARE DONE, STOP EDITING BEYOND THIS POINT ! +# +### + +function _output { + case "$1" in + oops) + _args="${*:2}"; _prefix="(Script Halted!)"; + echo -e "\\n$B$Y$_prefix$X $_args $R" >&2; exit 1 + ;; + okay) + _args="${*:2}"; _prefix="(Info)"; + echo -e "\\n$B$Y$_prefix$C $_args $R" >&2; exit 1 + ;; + debug) + _args="${*:2}"; _prefix="(Debug)"; + [[ "$_debug" == true ]] && echo -e "$Y$_prefix$C $_args $R" + ;; + *) + _args="${*:1}"; _prefix="(Info)"; + echo -e "\\n$_prefix $_args" + ;; + esac +} + +[ "$EUID" -eq 0 ] && _output oops "*!* This script should not be run using sudo, or as the root user!" +Y="\\033[33m"; C="\\033[36m"; R="\\033[0m" # theme + +_launcherDir=$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || \ + _output oops "Could not resolve the directory containing ${BASH_SOURCE[0]}." +cd -- "$_launcherDir" || _output oops "Could not enter the server directory: $_launcherDir" + +# 'better comparing' fix to replace: function version_gt() { test "$(printf '%s\n' "$@"|sort -V|head -n 1)" -ge "$1"; } +function version_gt() { + local result="$1" + local value="$2" + + # When the versions (strings) has fewer components we need to properly split the version strings into arrays + IFS='.' read -ra result_parts <<< "$result" + IFS='.' read -ra value_parts <<< "$value" + + # So we can then compare each part of the version (using 0 for missing parts). + for ((i = 0; i < ${#value_parts[@]}; i++)); do + result_part="${result_parts[i]:-0}" + value_part="${value_parts[i]}" + + if [[ "$result_part" -gt "$value_part" ]]; then + # true + return 0 + elif [[ "$result_part" -lt "$value_part" ]]; then + # false + return 1 + fi + done + + # return true when they're equal or have fewer components. + return 0 +} + +function binExists() { type "$1">/dev/null 2>&1; } +function binDetails() { + _cmd="$1" + _cmdpath=$(command -V "$_cmd" | awk '{print $3}') + _cmdversion=$($_cmd -version 2>&1 | awk -F '"' '/version/ {print $2}') +} + +function _lowercaseValue() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +function _findJarCaseInsensitive() { + local _wantedLower="" + local _candidate="" + local _candidateName="" + local _candidateLower="" + + _foundJar="" + _wantedLower=$(_lowercaseValue "$1") + for _candidate in ./*; do + [[ -f "$_candidate" ]] || continue + [[ -L "$_candidate" ]] && continue + _candidateName=${_candidate#./} + _candidateLower=$(_lowercaseValue "$_candidateName") + if [[ "$_candidateLower" == "$_wantedLower" ]]; then + _foundJar="$_candidateName" + return 0 + fi + done + return 1 +} + +function _findLatestPaperBuildJar() { + local _version="$1" + local _prefix="paper-${_version}-" + local _candidate="" + local _candidateName="" + local _candidateLower="" + local _candidateBuild="" + local _candidateBuildNumber=0 + + _latestPaperJar="" + _latestPaperBuild=-1 + for _candidate in ./*; do + [[ -f "$_candidate" ]] || continue + [[ -L "$_candidate" ]] && continue + _candidateName=${_candidate#./} + _candidateLower=$(_lowercaseValue "$_candidateName") + case "$_candidateLower" in + "${_prefix}"*.jar) + _candidateBuild=${_candidateLower#"$_prefix"} + _candidateBuild=${_candidateBuild%.jar} + case "$_candidateBuild" in + ""|*[!0-9]*) continue ;; + esac + _candidateBuildNumber=$((10#$_candidateBuild)) + if [[ "$_candidateBuildNumber" -gt "$_latestPaperBuild" ]]; then + _latestPaperBuild="$_candidateBuildNumber" + _latestPaperJar="$_candidateName" + fi + ;; + esac + done + + [[ -n "$_latestPaperJar" ]] || return 1 + _engineJar="$_latestPaperJar" + _serverJar="$_latestPaperJar" + return 0 +} + +function _selectEngineJar() { + local _engineLower="" + + _engineJar="" + _engineLower=$(_lowercaseValue "$_engine") + if [[ -n "$_engine" ]]; then + if [[ "$_engineLower" == "paper" ]] && _findLatestPaperBuildJar "$_minecraftVersion"; then + _output debug "Selected latest Paper build for Minecraft $_minecraftVersion: $_engineJar (build $_latestPaperBuild)" + return 0 + fi + + _serverJar="$_engine-$_minecraftVersion.jar" + if _findJarCaseInsensitive "$_serverJar"; then + _engineJar="$_foundJar" + _serverJar="$_foundJar" + return 0 + fi + _output oops "Oops, we did not find $_serverJar, please check your configuration." + fi + + if _findLatestPaperBuildJar "$_minecraftVersion"; then + _output debug "Selected latest Paper build for Minecraft $_minecraftVersion: $_engineJar (build $_latestPaperBuild)" + return 0 + fi + _serverJar="paper-$_minecraftVersion.jar" + if _findJarCaseInsensitive "$_serverJar"; then + _engineJar="$_foundJar" + _serverJar="$_foundJar" + return 0 + fi + _serverJar="spigot-$_minecraftVersion.jar" + if _findJarCaseInsensitive "$_serverJar"; then + _engineJar="$_foundJar" + _serverJar="$_foundJar" + return 0 + fi + _output oops "Oops, we did not find a paper or spigot jar for Minecraft $_minecraftVersion, please read a manual." +} + +_launcherRunLock="" +_launcherRunLockMayRelease=true + +function _releaseLauncherRunLock() { + local _ownerFile="" + local _ownerPid="" + [[ "$_launcherRunLockMayRelease" == true ]] || return 0 + [[ -n "$_launcherRunLock" ]] || return 0 + [[ -d "$_launcherRunLock" && ! -L "$_launcherRunLock" ]] || return 0 + _ownerFile="$_launcherRunLock/owner.pid" + [[ -f "$_ownerFile" && ! -L "$_ownerFile" ]] || return 0 + IFS= read -r _ownerPid < "$_ownerFile" || true + if [[ "$_ownerPid" == "$$" ]]; then + rm -- "$_ownerFile" 2>/dev/null || true + rmdir "$_launcherRunLock" 2>/dev/null || true + fi +} + +function _preserveLauncherRunLockOnSignal() { + local _signalName="$1" + _launcherRunLockMayRelease=false + _output oops "Launcher received $_signalName while Java may still be alive. The server-launch lock was preserved; confirm the JVM is fully stopped before removing it manually." +} + +function _claimLauncherRunLock() { + local _runtimeDirectory="paperscript" + local _locksDirectory="$_runtimeDirectory/locks" + local _lockDirectory="$_locksDirectory/server-launch" + local _ownerFile="$_lockDirectory/owner.pid" + local _ownerPid="" + + # Servers without PaperScript keep their historical launcher behavior. + [[ -d "$_runtimeDirectory" ]] || return 0 + [[ ! -L "$_runtimeDirectory" ]] || \ + _output oops "PaperScript runtime is a symlink; refusing to create the server-launch lock." + if [[ -e "$_locksDirectory" ]]; then + [[ -d "$_locksDirectory" && ! -L "$_locksDirectory" ]] || \ + _output oops "PaperScript lock path is not a regular directory: $_locksDirectory" + else + mkdir -m 700 "$_locksDirectory" || \ + _output oops "Could not create the PaperScript lock directory: $_locksDirectory" + fi + + if ! mkdir -m 700 "$_lockDirectory" 2>/dev/null; then + [[ -d "$_lockDirectory" && ! -L "$_lockDirectory" ]] || \ + _output oops "PaperScript server-launch lock is not a regular directory: $_lockDirectory" + if [[ -f "$_ownerFile" && ! -L "$_ownerFile" ]]; then + IFS= read -r _ownerPid < "$_ownerFile" || true + elif [[ -e "$_ownerFile" || -L "$_ownerFile" ]]; then + _output oops "PaperScript server-launch owner file is unsafe: $_ownerFile" + fi + if [[ "$_ownerPid" =~ ^[0-9]+$ ]]; then + _output oops "A 1MB-minecraft.sh launch lock already exists for this server (recorded wrapper PID $_ownerPid). Refusing automatic recovery because Java may still be running after its wrapper exits. Confirm the server is fully stopped before manually removing $_lockDirectory." + fi + _output oops "A 1MB-minecraft.sh launch lock already exists for this server. Refusing automatic recovery because Java may still be running. Confirm the server is fully stopped before manually removing $_lockDirectory." + fi + + _launcherRunLock="$_lockDirectory" + trap _releaseLauncherRunLock EXIT + trap '_preserveLauncherRunLockOnSignal HUP' HUP + trap '_preserveLauncherRunLockOnSignal INT' INT + trap '_preserveLauncherRunLockOnSignal QUIT' QUIT + trap '_preserveLauncherRunLockOnSignal TERM' TERM + (umask 077; printf '%s\n' "$$" > "$_ownerFile") || \ + _output oops "Could not record ownership of the server-launch lock: $_ownerFile" +} + +function _recordLastLaunchedJar() { + local _markerDirectory="paperscript" + + # Servers without PaperScript can keep using this launcher normally. Once the + # runtime directory exists, the marker is required so cleanup can protect the + # exact jar selected for this launch. + if [[ ! -d "$_markerDirectory" ]]; then + _output debug "PaperScript runtime directory was not found; skipping the last-launched jar marker." + return 0 + fi + binExists "python3" || \ + _output oops "python3 is required to safely claim the launcher-selected jar for PaperScript retention." + + if ! python3 - "$_launcherDir" "$_engineJar" <<'PY' +import fcntl +import os +import stat +import sys +import tempfile +from pathlib import Path + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_write_text(path: Path, content: str) -> None: + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + fsync_directory(path.parent) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary.exists(): + temporary.unlink() + + +try: + server_root = Path(sys.argv[1]).resolve(strict=True) + selected_name = sys.argv[2] + if not selected_name or selected_name != Path(selected_name).name or "/" in selected_name or "\\" in selected_name: + raise RuntimeError(f"selected jar is not a safe basename: {selected_name!r}") + + runtime = server_root / "paperscript" + if runtime.is_symlink() or not runtime.is_dir(): + raise RuntimeError(f"PaperScript runtime is not a regular directory: {runtime}") + runtime.resolve().relative_to(server_root) + + locks = runtime / "locks" + locks.mkdir(mode=0o700, exist_ok=True) + if locks.is_symlink() or not locks.is_dir(): + raise RuntimeError(f"PaperScript lock path is not a regular directory: {locks}") + lock_path = locks / "paper-jars.lock" + if lock_path.is_symlink(): + raise RuntimeError(f"PaperScript lock file is a symlink: {lock_path}") + + with lock_path.open("a+", encoding="utf-8") as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + + selected_path = server_root / selected_name + if selected_path.is_symlink(): + raise RuntimeError(f"selected jar became a symlink: {selected_path}") + selected_stat = selected_path.lstat() + if not stat.S_ISREG(selected_stat.st_mode): + raise RuntimeError(f"selected jar is no longer a regular file: {selected_path}") + + marker = runtime / "last-launched-jar.txt" + if marker.is_symlink() or (marker.exists() and not marker.is_file()): + raise RuntimeError(f"last-launched marker is not a regular file: {marker}") + rollback = runtime / ".launcher-marker-rollback" + if rollback.is_symlink() or (rollback.exists() and not rollback.is_file()): + raise RuntimeError(f"launcher marker rollback path is unsafe: {rollback}") + + previous_name = "" + if marker.exists() and marker.stat().st_size <= 512: + candidate = marker.read_text(encoding="utf-8").strip() + previous_path = server_root / candidate + if ( + candidate + and candidate == Path(candidate).name + and "/" not in candidate + and "\\" not in candidate + and not previous_path.is_symlink() + and previous_path.is_file() + ): + previous_name = candidate + + atomic_write_text(rollback, previous_name + "\n") + atomic_write_text(marker, selected_name + "\n") +except Exception as error: + print(f"Could not safely record the launcher-selected jar: {error}", file=sys.stderr) + raise SystemExit(1) +PY + then + _output oops "Could not safely claim the launcher-selected jar." + fi + _output debug "Recorded launcher-selected jar: $_engineJar" +} + +function _finalizeLauncherMarker() { + local _outcome="$1" + [[ -d "paperscript" ]] || return 0 + + if ! python3 - "$_launcherDir" "$_engineJar" "$_outcome" <<'PY' +import fcntl +import os +import stat +import sys +import tempfile +from pathlib import Path + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_write_text(path: Path, content: str) -> None: + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + fsync_directory(path.parent) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary.exists(): + temporary.unlink() + + +try: + server_root = Path(sys.argv[1]).resolve(strict=True) + selected_name = sys.argv[2] + outcome = sys.argv[3] + if outcome not in {"success", "failure"}: + raise RuntimeError(f"unknown launcher finalization outcome: {outcome}") + + runtime = server_root / "paperscript" + runtime.resolve().relative_to(server_root) + marker = runtime / "last-launched-jar.txt" + rollback = runtime / ".launcher-marker-rollback" + lock_path = runtime / "locks" / "paper-jars.lock" + if lock_path.is_symlink(): + raise RuntimeError(f"PaperScript lock file is a symlink: {lock_path}") + + with lock_path.open("a+", encoding="utf-8") as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + if rollback.is_symlink() or (rollback.exists() and not rollback.is_file()): + raise RuntimeError(f"launcher marker rollback path is unsafe: {rollback}") + + if outcome == "failure" and rollback.exists(): + if marker.is_symlink() or not marker.is_file(): + raise RuntimeError(f"last-launched marker changed type before rollback: {marker}") + current_name = marker.read_text(encoding="utf-8").strip() + if current_name != selected_name: + raise RuntimeError( + f"last-launched marker changed after this launch selected {selected_name}: {current_name}" + ) + previous_name = rollback.read_text(encoding="utf-8").strip() + if previous_name: + if ( + previous_name != Path(previous_name).name + or "/" in previous_name + or "\\" in previous_name + ): + raise RuntimeError("launcher rollback marker does not contain a safe basename") + previous_path = server_root / previous_name + previous_stat = previous_path.lstat() + if previous_path.is_symlink() or not stat.S_ISREG(previous_stat.st_mode): + raise RuntimeError(f"previous launcher-selected jar is no longer safe: {previous_path}") + atomic_write_text(marker, previous_name + "\n") + else: + marker.unlink() + fsync_directory(runtime) + + if rollback.exists(): + rollback.unlink() + fsync_directory(runtime) +except Exception as error: + print(f"Could not safely finalize the launcher-selected jar marker: {error}", file=sys.stderr) + raise SystemExit(1) +PY + then + _output oops "Could not safely finalize the launcher-selected jar marker." + fi +} + +if binExists "java"; then + binDetails "java" + if version_gt "$_cmdversion" "$_minJavaVersion"; then + if [ -z "$_javaBin" ]; then + _output debug "_javaBin is empty, trying to auto discover java .." + if [ -z "$_cmdpath" ]; then + _output oops "Path to java bin was found empty, maybe set _javaBin manually" + else + _output debug "Path to java ($_cmdversion) auto discovered: $_cmdpath" + _javaBin="$_cmdpath" + fi + else + # todo: Reconsider how to approach this, if _javaBin is set, check that. If that fails, try auto discovery. + if [[ -f "$_javaBin" ]]; then + _output debug "Path to java was set in _javaBin, found it and trying to use this instead of auto discovery." + else + _output oops "Could not find $_javaBin, leave _javaBin empty for auto discovery or install java properly." + fi + fi + _output debug "Installed $_cmd version $_cmdversion is newer than $_minJavaVersion (this is great)!" + else + _output oops "Installed $_cmd version $_cmdversion is NOT newer \\n -> Please upgrade to the minimal required version: $_minJavaVersion " + fi +else + _output oops "java was not found, please install it for this operating system \\n -> https://www.digitalocean.com/community/tutorials?q=install+java" +fi + +# before we continue, let's select the latest matching Paper build or a legacy fallback +_claimLauncherRunLock +_selectEngineJar +_recordLastLaunchedJar + +[[ "$_eula" == true ]] && _javaParams="${_javaParams} -Dcom.mojang.eula.agree=true" + +_startJVM="$_javaBin $_javaMemory $_javaParams -jar $_engineJar $_engineParams $_noGui" +$_startJVM <&0 & +_jvmPid=$! +wait "$_jvmPid" +_jvmStatus=$? +if [[ "$_jvmStatus" -eq 0 ]]; then + _finalizeLauncherMarker success +else + _finalizeLauncherMarker failure + _output oops "Failed to start the jvm for some reason." +fi + +#EOF Copyright (c) 1977-2026 - Floris Fiedeldij Dop - https://scripts.1moreblock.com diff --git a/README.md b/README.md index 8a08356..af5358d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PaperScript -PaperScript is a dependency-free updater for Paper servers that uses the current PaperMC Fill v3 downloads service. +PaperScript is a dependency-free, manually invoked Paper JAR staging tool that uses the current PaperMC Fill v3 downloads service. It downloads a verified, versioned JAR beside the one already in use; it never stops, starts, restarts, or signals the server. It is designed for the layout where your server root stays readable, while PaperScript keeps its own files in a visible `paperscript/` directory: @@ -14,14 +14,16 @@ It is designed for the layout where your server root stays readable, while Paper /anydirectory/paperscript/config.example.json /anydirectory/paperscript/config.json /anydirectory/paperscript/state.json +/anydirectory/paperscript/last-launched-jar.txt /anydirectory/paperscript/cache/ /anydirectory/paperscript/downloads/ -/anydirectory/paperscript/backups/ +/anydirectory/paperscript/backups/jars/26.2/ +/anydirectory/paperscript/locks/ /anydirectory/paperscript/logs.log /anydirectory/paperscript/todo.log ``` -This makes it easy to `git pull`, wipe PaperScript runtime files, or move between macOS and Ubuntu without flooding the server root with updater clutter. +This keeps staging-tool clutter out of the server root and makes the disposable cache, download workspace, and logs easy to clean. Preserve `config.json`, `state.json`, `last-launched-jar.txt`, and the tracked PaperScript source files unless you intentionally want to reset them. ## Why Python Instead Of Bash @@ -40,71 +42,86 @@ Bash still has a place here, which is why the launcher remains a simple `papersc - Sends a custom User-Agent by default: `mrfloris-PaperScript/2.0 (https://github.com/mrfdev/PaperScript)` - Finds the latest stable Paper version and latest stable build automatically. -- Can inspect and install the latest preview release that is newer than the current stable line, preferring `BETA` and falling back to `ALPHA`. -- Only auto-updates when the stable build is newer for the same version. -- Prompts before cross-version upgrades. -- Prompts before downgrades. +- Can inspect and stage the latest preview release that is newer than the current stable line, preferring `BETA` and falling back to `ALPHA`. +- Stages a newer same-version stable build only when the command is run manually. +- Prompts before staging a newer or older Minecraft version family. - Supports forced re-download of the same build with `--force`. -- Verifies downloads against the API-provided SHA-256. -- Stores the installed SHA-256 in `state.json` for later `verify` checks. -- Caches Paper API metadata locally so repeated release checks are faster. -- Backs up the current jar before replacing it. -- Keeps only the newest 10 backups by default. -- Can trim backups to a chosen count with `cleanup --backups --keep N`. -- Detects likely running servers using `server-port`, process checks, and tmux-aware graceful stop attempts. -- Uses tmux by preference for graceful stop and falls back to `SIGTERM`. +- Requires the Paper API size and SHA-256 before staging and never writes beyond the declared size. +- Recomputes every requested latest-version/channel policy, or revalidates an explicitly requested exact build, against fresh Paper API data immediately before staging; cached metadata is never artifact authorization. +- Preflights server-root write access, durable directory sync, and enough free space for the JAR plus a safety reserve. +- Verifies the exact private staging inode against the API size/SHA-256 and checks its executable manifest, Main-Class bytecode, and every ZIP entry CRC. +- Stores staged-build identity and SHA-256 in `state.json` for later fail-closed `verify` checks. +- Caches Paper API metadata locally through an owner-checked, descriptor-bound directory and atomic private regular files so repeated read-only release checks are faster. +- Escapes untrusted terminal controls from API, HTTP, and other dynamic text before console or log output. +- Publishes the verified download atomically as `Paper--.jar` without replacing an existing JAR. +- Uses the launcher's atomic `last-launched-jar.txt` marker to protect the JAR selected for the last start. +- Keeps the last-launched JAR plus the newest staged JAR in the server root by default. +- While a launcher/JVM run is in flight, temporarily protects its one prior rollback JAR as well. +- Archives older exact same-version numeric builds under `paperscript/backups/jars//` and keeps five by default. +- Provides explicit `cleanup --server-jars --keep N --dry-run` reconciliation. +- Serializes staging and root-JAR cleanup with a per-server lock. +- Refuses overlapping launcher runs with a fail-closed lock held for the JVM lifetime. +- Detects likely running servers and tmux sessions for read-only status only. - Supports `--dry-run`, `--quiet`, `--no-color`, and per-server config defaults. - Supports color themes and a compact or full status view. -- Keeps updater runtime files isolated inside `paperscript/`. +- Keeps each target server's runtime files isolated inside that server's `paperscript/` directory. ## Requirements - Python `3.9+` - `python3` available on your path -- `tmux` if you want graceful stop support +- `tmux` only if you want its session shown in read-only status No third-party Python packages are required. ## Setup -Clone the repo into the server directory or a test directory: +For a central checkout that targets an existing server, clone separately and pass the server root explicitly: ```bash -git clone https://github.com/mrfdev/PaperScript.git -cd PaperScript +git clone https://github.com/mrfdev/PaperScript.git /opt/PaperScript +cd /opt/PaperScript chmod +x paperscript.sh paperscript/paperscript.py +./paperscript.sh --server-dir /srv/minecraft/live status ``` +For the compact layout shown above, place `paperscript.sh` and the tracked `paperscript/` directory directly in the server root. Do not run from a child checkout without `--server-dir`, because the current working directory is the default target. + If you want to initialize or repair the runtime files manually: ```bash -./paperscript.sh init +./paperscript.sh --server-dir /srv/minecraft/live init ``` -That creates or repairs the local runtime pieces in `paperscript/` and asks for confirmation before it changes anything. +In a compact in-root installation, the shorter `./paperscript.sh init` is equivalent. App startup may create target-local config, directories, and logging scaffolding; `init` asks before creating or repairing its remaining runtime files. ## Refreshing An Existing Live Server If you already run a live server and want to replace an older local PaperScript checkout with a fresh copy from GitHub, the normal safe path is: ```bash -./paperscript.sh status -./paperscript.sh update --dry-run -./paperscript.sh update +./paperscript.sh --server-dir /srv/minecraft/live status +./paperscript.sh --server-dir /srv/minecraft/live update --dry-run +./paperscript.sh --server-dir /srv/minecraft/live update ``` -For servers that use the default jar naming pattern, such as `Paper-26.2-84.jar`, a fresh PaperScript checkout can detect the current jar, back it up, and replace it with the newest stable build. +These examples assume a central checkout. In the compact in-root layout, omit the repeated `--server-dir` option. + +PaperScript remains a drop-in component for server roots launched through `1MB-start.sh` and `1MB-minecraft.sh`. Replacing `paperscript.sh` and the tracked program files under `paperscript/` does not require a launcher edit, config migration, new package, or new command-line option. Preserve the server-local `paperscript/config.json`, `paperscript/state.json`, and `paperscript/last-launched-jar.txt`; the file locations, marker format, numeric JAR naming contract, and manual-start behavior are unchanged. + +For servers that use the required numeric naming pattern, such as `Paper-26.2-84.jar`, PaperScript detects the newest local build and stages the next verified build beside it. The updated `1MB-minecraft.sh` selects the greatest numeric build for its configured Minecraft version on the next manual start. -If your server uses a custom installed jar name such as `Paper-26.2.jar`, keep your local `paperscript/config.json` and `paperscript/state.json` or restore their equivalent settings after refreshing the checkout. In particular, if you changed: +Keep your local `paperscript/config.json`, `paperscript/state.json`, and `paperscript/last-launched-jar.txt` when refreshing the checkout. In particular, review changes to: -- `download_filename_pattern` - `default_channel` - `check_latest_channel_only` - `tmux_session` then deleting the whole `paperscript/` runtime directory will also delete those per-server preferences. -If you intentionally want a completely fresh PaperScript runtime, re-check `./paperscript.sh status` before `update` so you can confirm that the detected current jar and target install name still match your server layout. +PaperScript always stages the canonical `Paper--.jar` name. Legacy names such as `Paper-26.2.jar` remain launcher fallbacks, but PaperScript never overwrites them. + +If you intentionally want a fresh runtime, start once with the updated `1MB-minecraft.sh` before root-JAR cleanup. Until a valid marker exists, staging succeeds but automatic root cleanup is deliberately deferred. ## Quick Start @@ -114,13 +131,13 @@ Show the current state: ./paperscript.sh status ``` -Install the latest stable Paper release when appropriate: +Stage the latest stable Paper build for the launcher-selected Minecraft family: ```bash ./paperscript.sh update ``` -Force a re-download of the current latest stable build: +Force a re-download and verification of the latest stable build for that launcher family: ```bash ./paperscript.sh --force update @@ -142,16 +159,20 @@ Download that preview build: ### `update` -Checks the server directory, detects the latest stable Paper release, and installs it when appropriate. +Checks the server directory, finds the latest stable build for the launcher-selected family, and stages it beside existing JARs when appropriate. Behavior: -- If no current Paper jar is detected, it offers the latest stable build. -- If your current version matches the newest stable version, it only downloads when the build number is newer. -- If your current version and build already match the newest stable release, `--force update` re-downloads it. -- If the newest stable version is a newer Minecraft version, PaperScript asks before upgrading. -- If `server.properties` exists and a likely matching Java process is running, PaperScript asks how to proceed. -- If `--dry-run` is used, it reports what it would do without changing files or stopping anything. +- If no managed Paper JAR is detected, it offers the latest stable build. +- If the launcher-selected version has a newer stable build, it stages that build. +- If the newest staged build for the launcher family already matches stable, `--force update` re-downloads and verifies it without replacing the existing file. +- `update` stays on the launcher-selected Minecraft version even when a newer family exists; cross-version staging requires an explicit `download --version ...` command. +- Before network activity, PaperScript confirms that the server root can create a private file and durably sync directory entries. It also requires enough free space for the API-declared JAR size plus at least 64 MiB of remaining headroom (or 10% of the JAR size when that is larger). +- The response is capped at the API-declared size. The exact on-disk inode must match that size and SHA-256, contain one executable JAR manifest and its Main-Class bytecode, and pass CRC/decompression checks for every ZIP entry. +- The verified file is mode `0644`, fsynced, then atomically published under its numeric build filename without overwriting any path that appeared concurrently. +- PaperScript never asks to stop the server and contains no stop/kill path. +- After staging, valid launcher identity enables bounded root cleanup; missing/invalid identity defers cleanup without guessing. +- If `--dry-run` is used, it reports JAR/archive actions without staging, moving, or pruning JARs; normal target-local config, logging, and metadata-cache activity may still occur. Examples: @@ -160,14 +181,13 @@ Examples: ./paperscript.sh update ./paperscript.sh --force update ./paperscript.sh update --dry-run -./paperscript.sh --yes --quiet update ./paperscript.sh --no-color update ./paperscript.sh --server-dir /srv/mc/live update ``` ### `status` -Shows the current PaperScript and Paper server state, the newest stable release, and the newest preview release beyond stable when one exists. +Shows PaperScript, launcher, staged-JAR, retention, and release state, including the newest preview release beyond stable when one exists. The normal full view can include: @@ -175,18 +195,20 @@ The normal full view can include: - server directory and runtime directory - server label - tmux session name and whether it currently exists -- graceful stop command +- manual/external lifecycle policy - server properties detection - configured server port - running server detection -- current jar, full path, version, build, recorded install channel, and SHA-256 -- stored expected SHA-256 from the last PaperScript install +- newest managed jar, full path, version, build, staged channel, and SHA-256 +- last launcher-selected jar, including a clearly labelled legacy marker when applicable +- the predicted next JAR for the family in the last launcher marker, including legacy fallback state; PaperScript does not execute or infer later edits to `_minecraftVersion` +- stored expected SHA-256 from the last PaperScript stage - newest stable release - update status - newest channels for the current stable version - newest preview release beyond stable -- backup retention settings -- backup file count and cleanup suggestions when useful +- server-root retention and bounded archive settings +- historical backup/archive counts and cleanup suggestions when useful Status views: @@ -207,7 +229,7 @@ Examples: ### `stable` -Shows the latest stable Paper release overall and can install it directly. +Shows the latest stable Paper release overall and can stage it directly. This is useful when you want a clear stable overview without running a full update flow first. @@ -244,11 +266,23 @@ Examples: ### `verify` -Hashes the current installed jar and compares it against: +Under the per-server mutation lock, hashes the newest managed jar through a no-follow file +descriptor and confirms that its path, inode, contents, and newest-target selection remain +unchanged throughout verification. It compares the digest against: + +- every non-empty SHA-256 recorded for that same jar in `state.json` during the last + PaperScript stage +- a valid SHA-256 fetched from the Paper API for that exact version and build through a + fresh lookup that always bypasses local metadata cache contents +- the command also reports the recorded staging channel and current channel from the API -- the SHA-256 recorded in `state.json` during the last PaperScript install -- the expected SHA-256 from the live Paper API for that exact version and build -- the recorded install channel and current channel reported by the API +`verify` exits with status `0` only after the target remains stable and every applicable +digest matches. It exits with status `1` when the jar is missing or changes during the +check, recorded digest data is malformed or mismatched, the fresh API lookup fails or +cannot find the exact build, the API omits a valid SHA-256, or the jar differs from that +SHA-256. Optional historical state may be absent or name another jar; a matching fresh API +digest remains authoritative. Consequently, `verify` requires Paper API connectivity and +never treats cached metadata as sufficient verification. Examples: @@ -290,7 +324,7 @@ This is useful for questions like: Shows the newest available build per channel for one specific version, then offers to download one interactively. -If the selected build is already installed, PaperScript can offer a direct `Download it anyway?` confirmation so you can re-download the same jar without leaving the inspect flow. +If the selected build is already staged, PaperScript can offer a direct confirmation. An existing target is never overwritten: it is accepted only when its SHA-256 already matches PaperMC. Examples: @@ -304,7 +338,7 @@ Examples: Interactive version picker. It lists all available versions, lets you choose one by number, shows the newest builds for that version, and can then download it. -If the build you choose is already installed, `explore` can offer the same `Download it anyway?` flow as `inspect`. +If the build you choose is already staged, `explore` can offer the same confirmation flow as `inspect`. Examples: @@ -314,7 +348,7 @@ Examples: ### `download` -Downloads a chosen version or exact build on demand. +Downloads, verifies, and stages a chosen version or exact build on demand. Examples: @@ -334,13 +368,14 @@ Notes: - `--build` downloads that exact build number for the version. - The default channel comes from `config.json` and defaults to `STABLE`. - Version upgrades still prompt unless you add `--yes`. -- `--force` lets you re-download and reinstall the same build even if it is already present. +- `--force` allows an already-selected build through the command flow, but never overwrites an existing target path. +- PaperScript refuses same-version build downgrades because `1MB-minecraft.sh` always chooses the greatest numeric build, so an older file could not become the next launch. - Use `./paperscript.sh --force update` to re-download the current latest stable build. - Use `./paperscript.sh --force download --version --build ` to re-download one exact build. ### `cleanup` -Removes selected local runtime files and caches from `paperscript/`. +Removes selected local runtime files and caches, or explicitly reconciles versioned JARs in the server root. Default behavior: @@ -350,13 +385,19 @@ Default behavior: Targets: - `--downloads` - Delete staged downloads and temp files in `downloads/` + Delete old download workspace files in `downloads/` - `--backups` - Clean backup jars + Clean legacy top-level backup items while preserving the managed `backups/jars/` archive - `--backups --keep N` Keep the newest `N` backups and remove older ones +- `--server-jars` + Protect the valid launcher-marked JAR, greatest numeric next-start JAR, and any in-flight launcher rollback JAR, then archive older exact numeric builds for that same version +- `--server-jars --keep N` + Use `N` as the steady-state matching-root limit (minimum `2`); the default is `2`. When last-launched and newest are the same file, only one physical JAR is needed. An active launcher may temporarily add one prior rollback JAR, so the safe transient maximum is normally `3`. +- `--server-jars --version VERSION` + Restrict reconciliation to this version; it must agree with the launch marker target - `--all` - Clean downloads, backups, metadata cache, `__pycache__`, logs, and JSON state/config together + Clean downloads, legacy top-level backups, metadata cache, `__pycache__`, logs, and JSON state/config together. It never implies `--server-jars` and preserves `backups/jars/`. - `--metadata-cache` Delete cached Paper API metadata in `cache/` - `--pycache` @@ -371,7 +412,9 @@ Confirmation behavior: - cleanup explains what will be removed - cleanup asks for `y/N` confirmation by default - `--yes` skips the prompt -- `--dry-run` shows what would be deleted without removing anything +- `--dry-run` lists both root-to-archive moves and exact archive-cap prunes without removing anything + +`--server-jars` is fail-closed: it refuses to move anything when the launcher marker is missing, malformed, points outside the server root, identifies a symlink, or names another version. Other Minecraft versions, legacy names, partial downloads, malformed names, symlinks, plugin JARs, and unknown files are never managed. Examples: @@ -382,6 +425,9 @@ Examples: ./paperscript.sh cleanup --backups ./paperscript.sh cleanup --metadata-cache ./paperscript.sh cleanup --backups --keep 10 +./paperscript.sh cleanup --server-jars --dry-run +./paperscript.sh cleanup --server-jars --keep 2 +./paperscript.sh cleanup --server-jars --version 26.2 --keep 2 ./paperscript.sh cleanup --pycache ./paperscript.sh cleanup --logs ./paperscript.sh cleanup --json @@ -402,7 +448,7 @@ It can create: - `downloads/` - `backups/` -It always asks for confirmation unless you use `--yes`. +It asks before its listed repair actions unless you use `--yes`. PaperScript startup itself may already create target-local config, directories, and log scaffolding. Examples: @@ -421,7 +467,7 @@ Examples: - `--user-agent VALUE` Full custom User-Agent header. If omitted, PaperScript uses the built-in default. - `--tmux-session NAME` - tmux session to use for graceful stop. Defaults to config, `PAPERSCRIPT_TMUX_SESSION`, or `mcserver`. + tmux session to display in read-only status. Defaults to config, `PAPERSCRIPT_TMUX_SESSION`, or `mcserver`. - `--timeout SECONDS` HTTP timeout in seconds. Default comes from `config.json` and is `30` unless changed. - `--debug-http` @@ -431,9 +477,9 @@ Examples: - `--yes` Accept prompts automatically where it is safe to do so. - `--force` - Reinstall even if the same build is already present. Most useful with `update`, `stable --download`, `experimental --download`, or `download`. + Allow the same build to be selected again. PaperScript still refuses to overwrite an existing JAR whose checksum differs. - `--dry-run` - Show what would happen without downloading, moving jars, pruning backups, or stopping servers. + Show what would happen without downloading, moving jars, or pruning archives. - `--quiet` Suppress normal console output. Logs still go to `paperscript/logs.log`. - `--no-color` @@ -446,13 +492,7 @@ PaperScript also accepts these global flags after the command, so both styles wo ./paperscript.sh stable --download --yes --force ``` -For cron or scheduled tasks, the safest pattern is usually: - -```bash -./paperscript.sh --yes --quiet update -``` - -That keeps the run non-interactive, quiet on stdout, and still logged to `paperscript/logs.log`. +PaperScript is intended for manual invocation. This project does not recommend cron or unattended staging. For API troubleshooting, a good pattern is: @@ -486,7 +526,7 @@ python3 paperscript.py update ## Running Server Detection -If `server.properties` exists, PaperScript assumes the directory may be a live server directory and checks for a likely matching Java process. +If `server.properties` exists, `status` treats the directory as a possible live server and performs read-only checks for a likely matching Java process. It first uses the `server-port` value from `server.properties` and looks for a Java process listening on that exact TCP port. That makes it safer on a machine that runs several Minecraft servers at once. @@ -496,26 +536,13 @@ If port-based detection does not find anything, it falls back to: - command-line matching - working-directory matching -When it finds one, it offers: - -- graceful stop -- force stop -- upgrade anyway -- exit - -Graceful stop behavior: - -- first tries `tmux send-keys -t stop Enter` -- then waits for exit -- if that fails, falls back to `SIGTERM` - -Force stop uses `SIGKILL`. +Detection never controls that process. Staging does not run a stop command, send tmux keys, send Unix signals, start Java, or restart a tmux session. Stop the server manually through your normal CLI/tmux workflow when you are ready, then run `1MB-minecraft.sh`; it will select the greatest numeric build for its configured version. Examples: ```bash -./paperscript.sh --tmux-session production update -PAPERSCRIPT_TMUX_SESSION=test-server ./paperscript.sh update +./paperscript.sh --tmux-session production status +PAPERSCRIPT_TMUX_SESSION=test-server ./paperscript.sh status ``` ## Logging And Runtime Files @@ -527,19 +554,25 @@ PaperScript stores its runtime files inside the visible `paperscript/` directory - `paperscript/config.json` Local per-server config, intentionally ignored by git - `paperscript/state.json` - Last installed jar information, intentionally ignored by git + Last staged jar information, intentionally ignored by git +- `paperscript/last-launched-jar.txt` + Exact basename selected by the customized `1MB-minecraft.sh`, written atomically immediately before Java starts; a nonzero JVM exit restores the previous valid marker - `paperscript/logs.log` Activity log - `paperscript/downloads/` - Temporary and staged downloads + Legacy/diagnostic download workspace; active staging uses one private hidden temp inode in the server root so publication stays on the same filesystem - `paperscript/cache/` - Cached Paper API metadata used to speed up repeated checks -- `paperscript/backups/` - Previous jars moved out of the server root + Cached Paper API metadata used only to speed up discovery and read-only checks. PaperScript rejects unsafe cache directories and leaves, binds cache I/O to an opened directory descriptor, and freshly recomputes the requested staging policy. +- `paperscript/backups/jars//` + Bounded archive of older exact numeric Paper builds moved out of the server root +- `paperscript/locks/` + Per-server advisory staging/cleanup lock plus the fail-closed active-launch guard - `paperscript/todo.log` Deferred future ideas for the project -These runtime files are isolated on purpose so the server root stays clean and different servers can keep different local settings without git noise. +These runtime files are isolated on purpose so the server root stays clean and different `--server-dir` targets keep separate config, state, cache, logs, archive, marker, and lock files without git noise. + +If an older central checkout kept `config.json` or `state.json` beside `paperscript.py`, the first run against a separate `--server-dir` prints a migration warning and leaves those legacy files untouched. Review and manually copy only the settings/state that belong to that target; PaperScript does not guess which server shared legacy state belongs to. ## Config Defaults @@ -555,15 +588,13 @@ Current default config: "tmux_session": "mcserver", "default_channel": "STABLE", "check_latest_channel_only": "STABLE", - "allow_cross_version_auto_upgrade": false, "allow_same_version_build_upgrade": true, "keep_backups": 10, - "cleanup_backups_after_install": true, - "running_server_action": "ask", - "graceful_stop_command": "stop", + "keep_server_jars": 2, + "keep_archived_jars": 5, + "reconcile_server_jars_after_stage": true, "http_timeout_seconds": 30, "status_show_all_channels": true, - "download_filename_pattern": "Paper-{version}-{build}.jar", "log_file": "logs.log", "backup_dir": "backups", "downloads_dir": "downloads", @@ -593,21 +624,23 @@ Useful per-server settings: - `server_name` Friendly label for status output - `tmux_session` - Session to use for graceful stop + Session to display in read-only status - `keep_backups` - How many backup jars to keep after install -- `running_server_action` - Default behavior when a running server is detected + Retention for historical files in the legacy top-level `backups/` cleanup target +- `keep_server_jars` + Steady-state exact same-version numeric JAR limit (minimum `2`): valid last-launched plus greatest numeric next-start JAR. One in-flight launcher rollback JAR may temporarily exceed it. +- `keep_archived_jars` + Maximum exact numeric Paper JARs retained per version under `paperscript/backups/jars/` (minimum `1`) +- `reconcile_server_jars_after_stage` + Archive older matching root JARs after a successful stage, but only when launcher identity validates - `default_channel` Default download channel for `download --version` - `metadata_cache_enabled` - Enable or disable the local Paper API metadata cache + Enable or disable the local Paper API metadata cache. Cached metadata can influence display and preliminary selection, but fresh API data recomputes every latest-build policy and authenticates every exact-build staging operation. - `metadata_cache_ttl_seconds` How long cached metadata stays valid before PaperScript refreshes it -- `download_filename_pattern` - Controls the installed jar name, for example `Paper-{version}-{build}.jar` or `Paper-{version}.jar` - `quiet` - Make unattended runs silent by default + Suppress normal console output while retaining the activity log - `no_color` Disable ANSI colors by default - `color_theme` @@ -631,7 +664,7 @@ Useful per-server settings: ## Force Re-Downloading A Build -If you already have a jar installed but want the same file again anyway, use one of these: +If a build is already selected and you want PaperScript to re-check that request, use one of these: ```bash ./paperscript.sh --force update @@ -647,11 +680,11 @@ Inside `inspect` and `explore`, PaperScript can also offer: Download it anyway? [y/N] ``` -when the selected build is already installed. +when the selected build is already staged. An existing target is kept byte-for-byte and is only accepted when its checksum matches the Paper API. ## Example Workflows -Check a live server without changing anything: +Check a live server without changing server JARs or controlling its lifecycle: ```bash ./paperscript.sh status @@ -659,7 +692,7 @@ Check a live server without changing anything: ./paperscript.sh update --dry-run ``` -Update a dev server in the current directory: +Stage a newer build for a dev server in the current directory: ```bash ./paperscript.sh update @@ -693,6 +726,28 @@ Target a separate server directory: ./paperscript.sh --server-dir /Users/you/minecraft/test-server update ``` +## Test-Instance 1MB Launcher + +This repository includes a customized test-instance copy of `1MB-minecraft.sh` at the +server root. The copy records the SHA-256 of the original launcher it came from so its +changes can be reviewed and manually applied to the canonical 1MB source later. + +For the configured `_minecraftVersion`, the customized launcher: + +- prefers `Paper--.jar` +- compares build numbers numerically and selects the greatest build +- ignores other Minecraft versions, partial downloads, and malformed build names +- ignores symlink JAR candidates +- falls back to the legacy `paper-.jar` name when no versioned build exists +- writes the exact selected basename atomically to `paperscript/last-launched-jar.txt` before invoking Java +- resolves and enters its own directory first, so launching it from another working directory cannot select another server's JAR +- keeps a fail-closed launch lock for the JVM lifetime, refuses overlapping launches, and restores the prior valid marker when Java returns an error + +The launcher only chooses a jar when the server is started. It does not download jars, +stop a running server, or modify the external canonical 1MB source. + +The launcher never guesses that an abandoned launch lock is stale: the wrapper can die while Java remains alive. If `paperscript/locks/server-launch/` remains after a crash, first confirm that the Minecraft JVM is fully stopped, then remove only that lock directory before retrying the launcher. + ## Testing PaperScript does not keep a Minecraft server, world, plugin directory, or reusable server template in this repository. Those files would be large, environment-specific, and too easy to mix with production data. Tests use disposable server directories instead. @@ -701,11 +756,11 @@ Run the dependency-free unit and drift checks: ```bash python3 -m unittest discover -s tests -p 'test_*.py' -v -bash -n paperscript.sh tests/live-smoke.sh -python3 -m py_compile paperscript/paperscript.py tests/test_paperscript.py +bash -n paperscript.sh 1MB-minecraft.sh tests/live-smoke.sh +python3 -m py_compile paperscript/paperscript.py tests/test_paperscript.py tests/test_1mb_minecraft.py ``` -The unit suite verifies version ordering, stable-channel defaults, same-version build-upgrade behavior, preview selection, both supported jar naming styles, saved channel metadata, and agreement between runtime, tracked, and documented config defaults. +The unit suite verifies version ordering, stable-channel defaults, same-version build-upgrade behavior, preview selection, saved channel metadata, fresh exact-build authorization, fail-closed fresh-API verification, atomic and symlink-safe metadata caching, terminal-control neutralization, atomic state/config defaults, launcher marker publication and rollback, numeric latest-build selection, non-disruptive staging, disk/permission/durability preflight, response size caps, private-inode continuity, executable JAR/CRC validation, per-server and active-launch locking, fail-closed marker handling, symlink/version containment, dry-run pruning previews, root retention, and archive caps. Launcher tests use a disposable fake `java` executable and never start a real server. Run the opt-in live PaperMC smoke test: @@ -713,7 +768,7 @@ Run the opt-in live PaperMC smoke test: ./tests/live-smoke.sh ``` -The live test creates a temporary server root, uses `STABLE` for both release checks, enables same-version build upgrades, and deliberately uses the per-server compatibility pattern `Paper-{version}.jar`. It then downloads the latest stable Paper release, verifies its SHA-256, confirms `status` detects its version/build/channel/path, and runs `verify`. The temporary jar and runtime files are removed afterward. +The live test creates a temporary server root, uses `STABLE` for both release checks, stages the canonical `Paper-{version}-{build}.jar`, verifies its SHA-256, confirms `status` detects its version/build/channel/path, and runs `verify`. It never starts or stops a server. The temporary jar and runtime files are removed afterward. To retain the disposable directory for troubleshooting: @@ -727,12 +782,16 @@ GitHub Actions runs the network-free unit and syntax checks on current macOS and PaperScript keeps a live local todo file at [paperscript/todo.log](./paperscript/todo.log) for deferred ideas that are not implemented yet. -Current queued ideas include: +Current completed production foundations include manual non-disruptive transactional staging, fresh staging-policy authorization, descriptor-bound private atomic metadata-cache writes, terminal-safe dynamic output, per-server locking, atomic no-overwrite publication/state, bounded JAR retention, and latest-build launcher selection with an active marker. Remaining queued ideas include: + +- corrupt-cache preservation and diagnostics +- manual review/application of the launcher diff to the canonical `1MB-minecraft.sh` +- read-only doctor checks, machine-readable status, and granular documented exit codes +- review of the Codex Security report's Security Objectives and Assumptions sections +- broader failure-injection, CLI integration, and launcher compatibility tests -- optional update scheduling or smarter unattended workflows -- extra selective cleanup and repair helpers -- richer tmux and server control helpers such as start, stop, and restart -- additional smoke-test or planner-style validation modes +Full server, world, plugin, and BlueMap backups remain a separate operational concern so +staging a new Paper jar does not wait for very large backup jobs. ## API Notes diff --git a/paperscript/config.example.json b/paperscript/config.example.json index 511c5b0..bfb0dae 100644 --- a/paperscript/config.example.json +++ b/paperscript/config.example.json @@ -3,15 +3,13 @@ "tmux_session": "mcserver", "default_channel": "STABLE", "check_latest_channel_only": "STABLE", - "allow_cross_version_auto_upgrade": false, "allow_same_version_build_upgrade": true, "keep_backups": 10, - "cleanup_backups_after_install": true, - "running_server_action": "ask", - "graceful_stop_command": "stop", + "keep_server_jars": 2, + "keep_archived_jars": 5, + "reconcile_server_jars_after_stage": true, "http_timeout_seconds": 30, "status_show_all_channels": true, - "download_filename_pattern": "Paper-{version}-{build}.jar", "log_file": "logs.log", "backup_dir": "backups", "downloads_dir": "downloads", diff --git a/paperscript/paperscript.py b/paperscript/paperscript.py index 31e99cb..320e0bf 100755 --- a/paperscript/paperscript.py +++ b/paperscript/paperscript.py @@ -1,29 +1,37 @@ #!/usr/bin/env python3 -"""PaperScript: a PaperMC updater focused on safe, interactive server upgrades.""" +"""PaperScript: manually stage verified PaperMC jars without controlling the server.""" from __future__ import annotations import argparse +import fcntl import hashlib +import http.client import json +import math import os import re +import secrets import shutil -import signal +import stat import subprocess import sys +import tempfile import time +import zipfile +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Iterator, TextIO from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit from urllib.request import Request, urlopen APP_NAME = "PaperScript" -APP_VERSION = "5.0.2" -APP_BUILD = "050" +APP_VERSION = "5.2.0" +APP_BUILD = "052" APP_RELEASE = f"{APP_VERSION} build {APP_BUILD}" API_ROOT = "https://fill.papermc.io/v3/projects/paper" PROJECT_URL = "https://github.com/mrfdev/PaperScript" @@ -31,7 +39,14 @@ DEFAULT_CHANNEL = "STABLE" DEFAULT_TIMEOUT = 30 DEFAULT_USER_AGENT = f"mrfloris-PaperScript/2.0 ({PROJECT_URL})" +STAGE_SELECTION_EXACT = "exact" +STAGE_SELECTION_LATEST_CHANNEL = "latest-channel" +STAGE_SELECTION_LATEST_OVERALL = "latest-overall" +STAGE_SELECTION_LATEST_PREVIEW = "latest-preview" TRANSIENT_HTTP_CODES = {429, 500, 502, 503, 504, 520, 521, 522, 523, 524} +STAGING_FREE_SPACE_RESERVE_BYTES = 64 * 1024 * 1024 +MAX_JAR_MANIFEST_BYTES = 1024 * 1024 +JAR_MANIFEST_PATH = "META-INF/MANIFEST.MF" COMMAND_NAMES = { "update", "status", @@ -46,6 +61,17 @@ "download", } CURRENT_JAR_PATTERN = re.compile(r"^paper-(.+)-(\d+)\.jar$", re.IGNORECASE) +LEGACY_JAR_PATTERN = re.compile(r"^paper-(.+)\.jar$", re.IGNORECASE) +SAFE_VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +LAST_LAUNCHED_JAR_MARKER = "last-launched-jar.txt" +LAUNCHER_MARKER_ROLLBACK = ".launcher-marker-rollback" +DEPRECATED_CONFIG_KEYS = { + "allow_cross_version_auto_upgrade", + "cleanup_backups_after_install", + "running_server_action", + "graceful_stop_command", + "download_filename_pattern", +} ANSI_RESET = "\033[0m" ANSI_BOLD = "\033[1m" ANSI_DIM = "\033[2m" @@ -56,6 +82,7 @@ ANSI_BRIGHT_CYAN = "\033[96m" ANSI_MAGENTA = "\033[35m" ANSI_BRIGHT_WHITE = "\033[97m" +ANSI_SGR_PATTERN = re.compile(r"\x1b\[[0-9;]*m") COLOR_THEMES: dict[str, dict[str, str]] = { "default": { "key": ANSI_BRIGHT_CYAN, @@ -85,27 +112,108 @@ "hint": ANSI_YELLOW, }, } -TODO_TEMPLATE = """PaperScript todo - -- Future automation: optional update scheduling or smarter unattended workflows. -- Future cleanup polish: extra selective cleanup and repair helpers beyond the current safe set. -- Future tmux/server control: start, stop, restart, and richer session management helpers. -- Future validation mode: additional smoke-test or planner mode beyond today's dry-run behavior. +TODO_TEMPLATE = """PaperScript production todo + +Product direction and boundaries + +- [x] Keep PaperScript manually invoked. Remove cron, scheduling, and unattended-update recommendations. +- [x] Make update a non-disruptive staging operation: download and verify a new + Paper--.jar beside the active jar while the server keeps running. + PaperScript must not stop, kill, start, or restart the server, and must not replace + the jar currently in use. +- [x] Keep tmux lifecycle control manual and external to PaperScript. PaperScript may use + tmux information for read-only status and doctor checks, but update must not send + stop or start commands. +- [x] Keep full server, world, plugin, and BlueMap backup orchestration separate. A quick + Paper jar download must not wait for hundreds of gigabytes of backup work. Limit + PaperScript itself to safe jar retention and cleanup. + +Safe jar staging + +- [x] Add a per-server exclusive lock so overlapping manual runs cannot share, remove, + or overwrite each other's staged downloads or state. +- [x] Make staging transactional: require Paper API size and SHA-256, preflight server-root + write access, durable directory sync, and free-space reserve; stream into one private + same-filesystem inode without exceeding the declared size; verify on-disk size, + SHA-256, executable JAR structure, and every ZIP CRC; fsync; then atomically publish + Paper--.jar without overwriting an existing path. +- [x] Write config and state atomically with temp files, fsync, and os.replace; preserve + and report corrupt JSON instead of silently replacing it. +- [x] Write metadata cache atomically through validated directory descriptors and reject + unsafe cache directories and leaves. +- [ ] Preserve a corrupt cache for diagnosis before refreshing it. +- [x] Validate and contain every configured path. Reject filesystem roots, traversal, + unsafe symlinks, and jar filename patterns that are not plain .jar basenames. +- [x] Namespace runtime state and locks by canonical server directory so one PaperScript + checkout can safely target more than one server. + +1MB-minecraft.sh integration + +- [x] Copy the latest canonical 1MB-minecraft.sh into the disposable PaperScript test + instance and record its original SHA-256 for later manual comparison. +- [x] Customize the test-instance copy so, for its configured Minecraft version, it + recognizes Paper--.jar and selects the greatest numeric build + on the next manual start. Never select a jar from another Minecraft version. +- [x] Preserve compatibility with the existing paper-.jar naming fallback, ignore + .part and malformed files, handle case consistently, and use numeric rather than + lexicographic build ordering. +- [x] Add launcher tests for multiple builds, multiple Minecraft versions, legacy fallback, + malformed names, and build-number boundaries such as 9 versus 10. +- [x] Record the exact launcher-selected basename atomically before Java starts, resolve + the launcher's own directory, ignore symlink jar candidates, hold a fail-closed + launch lock for the JVM lifetime, and restore the prior marker on JVM failure. +- [ ] Manually review and apply the test-instance launcher diff at its canonical source, + then propagate it through the normal 1MB-minecraft.sh release/update flow rather than + editing every generated server copy. + +Bounded Paper jar retention + +- [x] Keep the valid last-launched jar plus the newest staged same-version jar in the + server root by default; temporarily protect one in-flight launcher rollback jar; + never infer or move an active jar without a valid marker. +- [x] Archive only exact regular non-symlink Paper--.jar files for + the marker version under paperscript/backups/jars// and cap that archive. +- [x] Add explicit cleanup --server-jars with confirmation, --keep, --version, and dry-run; + keep it out of cleanup --all and preserve the old cleanup --keep backup behavior. +- [x] Add adversarial tests for numeric ordering, versions, malformed names, symlinks, + invalid markers, archive caps, lock contention, and active-jar byte/inode stability. + +Validation and operator feedback + +- [ ] Add a read-only doctor command covering supported Java version, disk space, + permissions, API access, config/state validity, active and staged jars, and tmux + identity without controlling the tmux session. +- [x] Make verify fail closed: require a valid SHA-256 for production downloads and return + nonzero status for mismatch or unverifiable state. +- [ ] Add machine-readable JSON output and documented exit codes for current, update + available, staged, no-op, mismatch, degraded/API unavailable, and invalid config. +- [ ] Review the Codex Security report.md Security Objectives and Assumptions sections, + then reconcile accepted guarantees with repository documentation, tests, and deployment guidance. +- [ ] Add offline/degraded status with stale-cache fallback, log rotation and severity/run + IDs, a --version option, supported-Python CI, and reproducible tagged releases with + checksummed artifacts. + +Tests + +- [ ] Add hermetic CLI integration tests using a local HTTP fixture and disposable server + roots for download retries, corrupt responses/cache, checksum mismatch, and exits. +- [ ] Add failure-injection tests for concurrent runs, signals at every staging boundary, + full disks, path traversal and symlinks, corrupt JSON, and cross-filesystem paths. +- [ ] Add manual or scheduled project CI smoke coverage for Java/Paper compatibility without + turning server updates themselves into scheduled production automation. """ DEFAULT_CONFIG: dict[str, Any] = { "server_name": None, "tmux_session": "mcserver", "default_channel": "STABLE", "check_latest_channel_only": "STABLE", - "allow_cross_version_auto_upgrade": False, "allow_same_version_build_upgrade": True, "keep_backups": 10, - "cleanup_backups_after_install": True, - "running_server_action": "ask", - "graceful_stop_command": "stop", + "keep_server_jars": 2, + "keep_archived_jars": 5, + "reconcile_server_jars_after_stage": True, "http_timeout_seconds": 30, "status_show_all_channels": True, - "download_filename_pattern": "Paper-{version}-{build}.jar", "log_file": "logs.log", "backup_dir": "backups", "downloads_dir": "downloads", @@ -128,6 +236,48 @@ "list_versions_channel_delay_ms": 150, "list_versions_continue_on_error": True, } +BOOLEAN_CONFIG_KEYS = { + "allow_same_version_build_upgrade", + "reconcile_server_jars_after_stage", + "status_show_all_channels", + "metadata_cache_enabled", + "confirm_before_force_download", + "confirm_before_downgrade", + "auto_detect_server_by_port", + "fallback_process_detection", + "quiet", + "no_color", + "debug_http", + "list_versions_continue_on_error", +} +INTEGER_CONFIG_MINIMUMS = { + "keep_backups": 0, + "keep_server_jars": 2, + "keep_archived_jars": 1, + "http_timeout_seconds": 1, + "metadata_cache_ttl_seconds": 0, + "http_retries": 0, + "list_versions_channel_delay_ms": 0, +} +NUMBER_CONFIG_MINIMUMS = { + "http_retry_backoff_seconds": 0.0, +} +STRING_CONFIG_KEYS = { + "tmux_session", + "log_file", + "backup_dir", + "downloads_dir", + "metadata_cache_dir", +} +OPTIONAL_STRING_CONFIG_KEYS = {"server_name", "contact", "user_agent"} +CONFIG_CHOICES = { + "default_channel": {"ALPHA", "BETA", "STABLE", "RECOMMENDED"}, + "check_latest_channel_only": {"ALPHA", "BETA", "STABLE", "RECOMMENDED"}, + "color_theme": set(COLOR_THEMES), + "default_status_view": {"full", "compact"}, + "command_hint_mode": {"auto", "always", "never"}, + "release_link_mode": {"auto", "always", "never"}, +} class PaperScriptError(Exception): @@ -168,6 +318,12 @@ def filename(self) -> str: return f"Paper-{self.version}-{self.build_id}.jar" +@dataclass(frozen=True) +class UpdateSelection: + build: BuildInfo + staging_policy: str + + @dataclass class DownloadVerification: sha256: str @@ -175,6 +331,67 @@ class DownloadVerification: elapsed_seconds: float +@dataclass(frozen=True) +class ArtifactVerification: + sha256: str + size: int + entry_count: int + main_class: str + + +@dataclass(frozen=True) +class JarRetentionPlan: + version: str + last_launched: Path + kept: tuple[Path, ...] + to_archive: tuple[Path, ...] + to_prune: tuple[Path, ...] = () + launch_rollback: Path | None = None + + +@dataclass(frozen=True) +class LauncherJarSelection: + path: Path + version: str + build: int | None + + @property + def is_numeric_build(self) -> bool: + return self.build is not None + + +class StyledConsoleText(str): + """Console text containing only PaperScript-owned styling around sanitized text.""" + + +class ServerMutationLock: + """Non-blocking advisory lock for one server root.""" + + def __init__(self, path: Path) -> None: + self.path = path + self.handle: TextIO | None = None + + def __enter__(self) -> "ServerMutationLock": + ensure_directory(self.path.parent) + self.handle = self.path.open("a+", encoding="utf-8") + try: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + self.handle.close() + self.handle = None + raise PaperScriptError( + f"Another PaperScript staging, verification, or server-jar cleanup is already running " + f"for this server ({self.path})." + ) from error + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + if self.handle is not None: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + self.handle = None + + def utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -249,6 +466,26 @@ def style_key_value( return f"{label_prefix}{label}:{ANSI_RESET} {value_prefix}{value}{ANSI_RESET}" +def terminal_safe_text(text: str) -> str: + """Render terminal control characters as visible text without reinterpreting them.""" + escaped: list[str] = [] + named = {"\n": r"\n", "\r": r"\r", "\t": r"\t"} + for character in str(text): + codepoint = ord(character) + if character in named: + escaped.append(named[character]) + elif codepoint < 0x20 or 0x7F <= codepoint <= 0x9F: + escaped.append(f"\\x{codepoint:02x}") + else: + escaped.append(character) + return "".join(escaped) + + +def plain_console_text(message: str) -> str: + """Remove PaperScript-owned SGR styling before writing a durable text log.""" + return ANSI_SGR_PATTERN.sub("", str(message)) + + def prompt_yes_no(question: str, default: bool = False, logger: "Logger | None" = None) -> bool: suffix = "[Y/n]" if default else "[y/N]" while True: @@ -256,7 +493,7 @@ def prompt_yes_no(question: str, default: bool = False, logger: "Logger | None" if logger is not None: reply = logger.prompt_input(prompt).strip().lower() else: - reply = input(prompt).strip().lower() + reply = input(terminal_safe_text(prompt)).strip().lower() if not reply: return default if reply in {"y", "yes"}: @@ -278,19 +515,19 @@ def prompt_choice( if logger is not None: logger.info(question) else: - print(question) + print(terminal_safe_text(question)) for key, label in choices: default_mark = " (default)" if default == key else "" if logger is not None: logger.log(f" {key}) {label}{default_mark}") else: - print(f" {key}) {label}{default_mark}") + print(terminal_safe_text(f" {key}) {label}{default_mark}")) valid = {key for key, _ in choices} while True: if logger is not None: reply = logger.prompt_input("> ").strip().lower() else: - reply = input("> ").strip().lower() + reply = input(terminal_safe_text("> ")).strip().lower() if not reply and default is not None: return default if reply in valid: @@ -318,6 +555,197 @@ def ensure_directory(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) +def path_is_within(path: Path, parent: Path) -> bool: + try: + path.resolve(strict=False).relative_to(parent.resolve(strict=False)) + return True + except ValueError: + return False + + +def paths_overlap(left: Path, right: Path) -> bool: + return path_is_within(left, right) or path_is_within(right, left) + + +def fsync_directory(path: Path, *, strict: bool = False) -> None: + """Persist a directory entry update where the platform supports it.""" + try: + descriptor = os.open(path, os.O_RDONLY) + except OSError as error: + if strict: + raise PaperScriptError(f"Could not open directory for durable sync {path}: {error}") from error + return + try: + os.fsync(descriptor) + except OSError as error: + if strict: + raise PaperScriptError(f"Could not durably sync directory {path}: {error}") from error + finally: + os.close(descriptor) + + +def atomic_write_text(path: Path, content: str, mode: int = 0o600) -> None: + """Write a small runtime file without exposing a partial/truncated value.""" + ensure_directory(path.parent) + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + temporary_path = Path(temporary_name) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + fsync_directory(path.parent) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary_path.exists(): + temporary_path.unlink() + + +def open_safe_cache_directory(path: Path) -> int | None: + """Open one owner-controlled cache directory for descriptor-relative operations.""" + try: + path.mkdir(parents=True, exist_ok=True, mode=0o700) + before = path.lstat() + except OSError: + return None + if ( + not stat.S_ISDIR(before.st_mode) + or (hasattr(os, "geteuid") and before.st_uid != os.geteuid()) + or stat.S_IMODE(before.st_mode) & 0o022 + ): + return None + + flags = os.O_RDONLY + flags |= getattr(os, "O_DIRECTORY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open(path, flags) + except OSError: + return None + try: + after = os.fstat(descriptor) + except OSError: + os.close(descriptor) + return None + if ( + not stat.S_ISDIR(after.st_mode) + or (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino) + or (hasattr(os, "geteuid") and after.st_uid != os.geteuid()) + or stat.S_IMODE(after.st_mode) & 0o022 + ): + os.close(descriptor) + return None + return descriptor + + +def read_safe_cache_text(directory_descriptor: int, leaf_name: str) -> str | None: + """Read one regular cache inode relative to an already-validated directory.""" + try: + before = os.stat( + leaf_name, + dir_fd=directory_descriptor, + follow_symlinks=False, + ) + except (FileNotFoundError, OSError): + return None + if ( + not stat.S_ISREG(before.st_mode) + or (hasattr(os, "geteuid") and before.st_uid != os.geteuid()) + or stat.S_IMODE(before.st_mode) & 0o022 + ): + return None + + flags = os.O_RDONLY + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open( + leaf_name, + flags, + dir_fd=directory_descriptor, + ) + except OSError: + return None + try: + after = os.fstat(descriptor) + if ( + not stat.S_ISREG(after.st_mode) + or (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino) + or (hasattr(os, "geteuid") and after.st_uid != os.geteuid()) + or stat.S_IMODE(after.st_mode) & 0o022 + ): + return None + with os.fdopen(descriptor, "r", encoding="utf-8") as handle: + descriptor = -1 + return handle.read() + except (OSError, UnicodeDecodeError): + return None + finally: + if descriptor >= 0: + os.close(descriptor) + + +def atomic_write_cache_text( + directory_descriptor: int, + leaf_name: str, + content: str, + mode: int = 0o600, +) -> None: + """Atomically replace a cache leaf without resolving the directory pathname again.""" + temporary_name = "" + descriptor = -1 + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + for _ in range(16): + temporary_name = f".{leaf_name}.{secrets.token_hex(16)}.tmp" + try: + descriptor = os.open( + temporary_name, + flags, + mode, + dir_fd=directory_descriptor, + ) + break + except FileExistsError: + continue + else: + raise PaperScriptError(f"Could not allocate a private cache file for {leaf_name}.") + + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.rename( + temporary_name, + leaf_name, + src_dir_fd=directory_descriptor, + dst_dir_fd=directory_descriptor, + ) + temporary_name = "" + os.fsync(directory_descriptor) + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary_name: + try: + os.unlink(temporary_name, dir_fd=directory_descriptor) + except FileNotFoundError: + pass + + def run_command(args: list[str]) -> subprocess.CompletedProcess[str]: try: return subprocess.run( @@ -374,6 +802,186 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def required_artifact_size(build: BuildInfo) -> int: + """Return trustworthy Fill size metadata or refuse to stage the artifact.""" + if isinstance(build.size, bool) or not isinstance(build.size, int) or build.size <= 0: + raise PaperScriptError( + f"Paper API did not provide a valid positive size for {build.filename}; staging was refused." + ) + return build.size + + +def normalized_sha256(value: object) -> str | None: + """Return one canonical SHA-256 digest or reject malformed metadata/state.""" + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", value): + return None + return value.lower() + + +def require_artifact_metadata(build: BuildInfo) -> int: + """Fail closed when Fill omits either field needed for safe staging.""" + if not isinstance(build.download_url, str) or terminal_safe_text(build.download_url) != build.download_url: + parsed_url = None + else: + try: + parsed_url = urlsplit(build.download_url) + except ValueError: + parsed_url = None + if ( + parsed_url is None + or parsed_url.scheme.casefold() != "https" + or not parsed_url.hostname + or parsed_url.username is not None + or parsed_url.password is not None + ): + raise PaperScriptError( + f"Paper API did not provide a safe HTTPS download URL for {build.filename}; staging was refused." + ) + if normalized_sha256(build.sha256) is None: + raise PaperScriptError( + f"Paper API did not provide a valid SHA-256 for {build.filename}; staging was refused." + ) + return required_artifact_size(build) + + +def sha256_descriptor(descriptor: int) -> str: + """Hash the already-open inode so verification cannot follow a replaced path.""" + digest = hashlib.sha256() + with os.fdopen(os.dup(descriptor), "rb") as handle: + handle.seek(0) + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def manifest_main_class(payload: bytes) -> str: + if len(payload) > MAX_JAR_MANIFEST_BYTES: + raise PaperScriptError( + f"JAR manifest exceeds the {format_bytes(MAX_JAR_MANIFEST_BYTES)} safety limit." + ) + try: + text = payload.decode("utf-8") + except UnicodeDecodeError as error: + raise PaperScriptError("JAR manifest is not valid UTF-8.") from error + + physical_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + main_section: list[str] = [] + for line in physical_lines: + if line.startswith(" "): + if not main_section: + raise PaperScriptError("JAR manifest starts with an invalid continuation line.") + main_section[-1] += line[1:] + continue + if not line: + break + main_section.append(line) + + values: list[str] = [] + for line in main_section: + key, separator, value = line.partition(":") + if separator and key.casefold() == "main-class": + values.append(value.strip()) + if len(values) != 1 or not values[0]: + raise PaperScriptError("JAR manifest must contain exactly one non-empty Main-Class entry.") + + main_class = values[0] + if ( + any(character.isspace() for character in main_class) + or "/" in main_class + or "\\" in main_class + or main_class.startswith(".") + or main_class.endswith(".") + or ".." in main_class + ): + raise PaperScriptError(f"JAR manifest has an unsafe Main-Class value: {main_class!r}.") + return main_class + + +def verify_paper_artifact_descriptor(descriptor: int, build: BuildInfo) -> ArtifactVerification: + """Verify exact downloaded bytes as an intact executable JAR before publication.""" + expected_size = required_artifact_size(build) + descriptor_stat = os.fstat(descriptor) + if not stat.S_ISREG(descriptor_stat.st_mode): + raise PaperScriptError(f"Staging file for {build.filename} is no longer a regular file.") + if descriptor_stat.st_size != expected_size: + raise PaperScriptError( + f"Downloaded size mismatch for {build.filename}: expected {expected_size}, " + f"got {descriptor_stat.st_size} bytes on disk." + ) + + digest = sha256_descriptor(descriptor) + expected_sha256 = str(build.sha256 or "") + if digest.lower() != expected_sha256.lower(): + raise PaperScriptError( + f"Checksum mismatch for {build.filename}: expected {expected_sha256}, got {digest}" + ) + + try: + with os.fdopen(os.dup(descriptor), "rb") as handle: + handle.seek(0) + with zipfile.ZipFile(handle, "r") as archive: + entries = archive.infolist() + regular_entries = [entry for entry in entries if not entry.is_dir()] + if not regular_entries: + raise PaperScriptError("JAR archive contains no files.") + + manifest_entries = [ + entry for entry in entries if entry.filename == JAR_MANIFEST_PATH + ] + if len(manifest_entries) != 1 or manifest_entries[0].is_dir(): + raise PaperScriptError( + f"JAR archive must contain exactly one regular {JAR_MANIFEST_PATH}." + ) + manifest_info = manifest_entries[0] + if manifest_info.file_size > MAX_JAR_MANIFEST_BYTES: + raise PaperScriptError( + f"JAR manifest exceeds the {format_bytes(MAX_JAR_MANIFEST_BYTES)} safety limit." + ) + main_class = manifest_main_class(archive.read(manifest_info)) + + main_class_path = main_class.replace(".", "/") + ".class" + main_class_entries = [ + entry for entry in entries if entry.filename == main_class_path + ] + if len(main_class_entries) != 1 or main_class_entries[0].is_dir(): + raise PaperScriptError( + f"JAR Main-Class {main_class!r} does not have exactly one {main_class_path} entry." + ) + with archive.open(main_class_entries[0], "r") as class_handle: + if class_handle.read(4) != b"\xca\xfe\xba\xbe": + raise PaperScriptError( + f"JAR Main-Class entry {main_class_path} is not a Java class file." + ) + + corrupt_entry = archive.testzip() + if corrupt_entry is not None: + raise PaperScriptError( + f"JAR CRC/decompression verification failed for entry {corrupt_entry!r}." + ) + except PaperScriptError: + raise + except ( + OSError, + EOFError, + RuntimeError, + ValueError, + NotImplementedError, + zipfile.BadZipFile, + zipfile.LargeZipFile, + ) as error: + raise PaperScriptError(f"Artifact is not an intact executable JAR: {error}") from error + + return ArtifactVerification( + sha256=digest, + size=descriptor_stat.st_size, + entry_count=len(regular_entries), + main_class=main_class, + ) + + class Logger: def __init__( self, @@ -397,11 +1005,10 @@ def _console_text(self, message: str) -> str: return color_text(message, theme["error"], True, bold=True) if ( lower.startswith("downloaded to") - or lower.startswith("installed ") + or lower.startswith("staged:") + or lower.startswith("archived old server-root jar:") or lower.startswith("backed up ") or lower.startswith("cleanup finished:") - or lower.startswith("server stopped") - or lower.startswith("server force-stopped") ): return color_text(message, theme["success"], True, bold=True) if "checksum verification: match" in lower: @@ -446,11 +1053,17 @@ def _console_text(self, message: str) -> str: return message def log(self, message: str) -> None: - line = f"[{utc_now()}] {message}" + if isinstance(message, StyledConsoleText): + log_message = terminal_safe_text(plain_console_text(message)) + console_message = str(message) + else: + log_message = terminal_safe_text(str(message)) + console_message = self._console_text(log_message) + line = f"[{utc_now()}] {log_message}" with self.log_path.open("a", encoding="utf-8") as handle: handle.write(line + "\n") if not self.quiet: - print(self._console_text(message)) + print(console_message) def error(self, message: str) -> None: self.log(f"ERROR: {message}") @@ -470,7 +1083,8 @@ def prompt_input(self, message: str) -> str: "A prompt was required, but no interactive terminal is available. Re-run with --yes or adjust config." ) try: - return input(color_text(message, self.theme["prompt"], self.use_color, bold=True)) + safe_message = terminal_safe_text(message) + return input(color_text(safe_message, self.theme["prompt"], self.use_color, bold=True)) except EOFError as error: raise PaperScriptError("Input stream closed while waiting for a reply. PaperScript cancelled the prompt.") from error @@ -502,28 +1116,52 @@ def _log_http(self, message: str) -> None: if self.logger is not None and not self.logger.quiet: self.logger.log(message) - def _cache_path(self, label: str) -> Path | None: + def _cache_leaf_name(self, label: str) -> str: + safe_label = re.sub(r"[^A-Za-z0-9._-]+", "_", label.strip()) + return f"{safe_label}.json" + + def _open_cache_directory(self) -> int | None: if not self.cache_enabled or self.cache_dir is None: return None - ensure_directory(self.cache_dir) - safe_label = re.sub(r"[^A-Za-z0-9._-]+", "_", label.strip()) - return self.cache_dir / f"{safe_label}.json" + return open_safe_cache_directory(self.cache_dir) + + def _cache_path(self, label: str) -> Path | None: + directory_descriptor = self._open_cache_directory() + if directory_descriptor is None or self.cache_dir is None: + return None + os.close(directory_descriptor) + return self.cache_dir / self._cache_leaf_name(label) def _load_cache(self, label: str) -> Any | None: - cache_path = self._cache_path(label) - if cache_path is None or not cache_path.exists(): + directory_descriptor = self._open_cache_directory() + if directory_descriptor is None: return None try: - payload = json.loads(cache_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + cache_text = read_safe_cache_text( + directory_descriptor, + self._cache_leaf_name(label), + ) + if cache_text is None: + return None + payload = json.loads(cache_text) + except json.JSONDecodeError: return None + finally: + os.close(directory_descriptor) if not isinstance(payload, dict): return None cached_at = payload.get("cached_at_epoch") data = payload.get("data") - if not isinstance(cached_at, (int, float)): + if isinstance(cached_at, bool) or not isinstance(cached_at, (int, float)): + return None + try: + cached_at_float = float(cached_at) + except (OverflowError, ValueError): return None - age_seconds = max(0.0, time.time() - float(cached_at)) + now = time.time() + if not math.isfinite(cached_at_float) or cached_at_float > now: + return None + age_seconds = now - cached_at_float if age_seconds > self.cache_ttl_seconds: return None if self.debug_http: @@ -531,15 +1169,23 @@ def _load_cache(self, label: str) -> Any | None: return data def _save_cache(self, label: str, data: Any) -> None: - cache_path = self._cache_path(label) - if cache_path is None: + directory_descriptor = self._open_cache_directory() + if directory_descriptor is None: return payload = { "cached_at": utc_now(), "cached_at_epoch": time.time(), "data": data, } - cache_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + atomic_write_cache_text( + directory_descriptor, + self._cache_leaf_name(label), + json.dumps(payload, indent=2, sort_keys=True) + "\n", + mode=0o600, + ) + finally: + os.close(directory_descriptor) def _request_json(self, url: str) -> Any: request = Request(url, headers={"User-Agent": self.user_agent, "Accept": "application/json"}) @@ -590,8 +1236,8 @@ def _request_json(self, url: str) -> Any: raise PaperScriptError(data.get("message") or f"API returned an error for {url}") return data - def get_project_versions(self) -> list[dict[str, Any]]: - cached = self._load_cache("versions") + def get_project_versions(self, *, use_cache: bool = True) -> list[dict[str, Any]]: + cached = self._load_cache("versions") if use_cache else None rich = cached if cached is not None else self._request_json(f"{API_ROOT}/versions") if cached is None: self._save_cache("versions", rich) @@ -613,7 +1259,7 @@ def get_project_versions(self) -> list[dict[str, Any]]: if versions: return sorted(versions, key=lambda item: parse_version(item["id"]).key(), reverse=True) - cached_simple = self._load_cache("project-root") + cached_simple = self._load_cache("project-root") if use_cache else None simple = cached_simple if cached_simple is not None else self._request_json(API_ROOT) if cached_simple is None: self._save_cache("project-root", simple) @@ -625,10 +1271,12 @@ def get_project_versions(self) -> list[dict[str, Any]]: flattened.append({"id": version_id, "group": group}) return sorted(flattened, key=lambda item: parse_version(item["id"]).key(), reverse=True) - def get_builds(self, version: str) -> list[BuildInfo]: + def get_builds(self, version: str, *, use_cache: bool = True) -> list[BuildInfo]: + if not SAFE_VERSION_PATTERN.fullmatch(version): + raise PaperScriptError(f"Unsafe Paper version identifier: {version!r}") try: cache_label = f"builds-{version}" - cached = self._load_cache(cache_label) + cached = self._load_cache(cache_label) if use_cache else None raw = cached if cached is not None else self._request_json(f"{API_ROOT}/versions/{version}/builds") if cached is None: self._save_cache(cache_label, raw) @@ -668,22 +1316,40 @@ def get_builds(self, version: str) -> list[BuildInfo]: normalized.sort(key=lambda item: item.build_id, reverse=True) return normalized - def get_latest_build(self, version: str, channel: str = DEFAULT_CHANNEL) -> BuildInfo | None: + def get_latest_build( + self, + version: str, + channel: str = DEFAULT_CHANNEL, + *, + use_cache: bool = True, + ) -> BuildInfo | None: channel_upper = channel.upper() - for build in self.get_builds(version): + for build in self.get_builds(version, use_cache=use_cache): if build.channel == channel_upper: return build return None - def get_build_by_id(self, version: str, build_id: int) -> BuildInfo | None: - for build in self.get_builds(version): + def get_build_by_id( + self, + version: str, + build_id: int, + *, + use_cache: bool = True, + ) -> BuildInfo | None: + for build in self.get_builds(version, use_cache=use_cache): if build.build_id == build_id: return build return None - def download_file(self, build: BuildInfo, destination: Path) -> DownloadVerification: + def download_file( + self, + build: BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> DownloadVerification: ensure_directory(destination.parent) request = Request(build.download_url, headers={"User-Agent": self.user_agent}) + expected_size = build.size if isinstance(build.size, int) and not isinstance(build.size, bool) else None for attempt in range(self.retries + 1): sha256 = hashlib.sha256() bytes_written = 0 @@ -691,14 +1357,40 @@ def download_file(self, build: BuildInfo, destination: Path) -> DownloadVerifica try: if self.debug_http: self._log_http(f"HTTP DOWNLOAD {attempt + 1}/{self.retries + 1}: {build.download_url}") - with urlopen(request, timeout=self.timeout) as response, destination.open("wb") as handle: - while True: - chunk = response.read(1024 * 1024) - if not chunk: - break - sha256.update(chunk) - bytes_written += len(chunk) - handle.write(chunk) + if destination_descriptor is None: + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + output_descriptor = os.open(destination, flags, 0o600) + else: + output_descriptor = os.dup(destination_descriptor) + try: + output_stat = os.fstat(output_descriptor) + if not stat.S_ISREG(output_stat.st_mode): + raise PaperScriptError( + f"Download destination for {build.filename} is not a regular file." + ) + with os.fdopen(output_descriptor, "wb") as handle: + output_descriptor = -1 + handle.seek(0) + handle.truncate(0) + with urlopen(request, timeout=self.timeout) as response: + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + if expected_size is not None and bytes_written + len(chunk) > expected_size: + raise PaperScriptError( + f"Download exceeded the Paper API size for {build.filename}: " + f"expected {expected_size} bytes." + ) + sha256.update(chunk) + bytes_written += len(chunk) + handle.write(chunk) + handle.flush() + finally: + if output_descriptor >= 0: + os.close(output_descriptor) elapsed_seconds = time.monotonic() - started_at if self.debug_http: self._log_http( @@ -708,8 +1400,6 @@ def download_file(self, build: BuildInfo, destination: Path) -> DownloadVerifica break except HTTPError as error: detail_raw = error.read().decode("utf-8", errors="ignore") if hasattr(error, "read") else "" - if destination.exists(): - destination.unlink() if error.code in TRANSIENT_HTTP_CODES and attempt < self.retries: wait_seconds = self.retry_backoff_seconds * (2 ** attempt) self._log_http( @@ -725,8 +1415,6 @@ def download_file(self, build: BuildInfo, destination: Path) -> DownloadVerifica message += " The Paper API or Cloudflare may be having a temporary issue; please retry." raise PaperScriptError(message) from error except URLError as error: - if destination.exists(): - destination.unlink() if attempt < self.retries: wait_seconds = self.retry_backoff_seconds * (2 ** attempt) self._log_http( @@ -735,6 +1423,20 @@ def download_file(self, build: BuildInfo, destination: Path) -> DownloadVerifica time.sleep(wait_seconds) continue raise PaperScriptError(f"Download failed: {error.reason}") from error + except (OSError, http.client.IncompleteRead) as error: + transient = isinstance( + error, + (TimeoutError, ConnectionError, http.client.IncompleteRead), + ) + if transient and attempt < self.retries: + wait_seconds = self.retry_backoff_seconds * (2 ** attempt) + self._log_http( + f"Transient download I/O error for {build.download_url}: {error}. " + f"Retrying in {wait_seconds:.1f}s..." + ) + time.sleep(wait_seconds) + continue + raise PaperScriptError(f"Download I/O failed for {build.filename}: {error}") from error if build.sha256: digest = sha256.hexdigest() @@ -777,6 +1479,7 @@ def resolve_color_theme(name: Any) -> dict[str, str]: def summarize_http_detail(detail: str) -> str: cleaned = re.sub(r"<[^>]+>", " ", detail) cleaned = " ".join(cleaned.split()) + cleaned = terminal_safe_text(cleaned) if not cleaned: return "" return f"{cleaned[:220]}..." if len(cleaned) > 220 else cleaned @@ -831,25 +1534,52 @@ class PaperScriptApp: def __init__(self, args: argparse.Namespace) -> None: self.args = args self.script_dir = Path(__file__).resolve().parent - self.runtime_dir = self.script_dir self.server_dir = self._resolve_server_dir() + self.server_runtime_dir = self.server_dir / "paperscript" + self.runtime_dir = self.server_runtime_dir + self.last_launched_jar_marker_path = ( + self.server_runtime_dir / LAST_LAUNCHED_JAR_MARKER + ) + self.jar_archive_dir = self.server_runtime_dir / "backups" / "jars" + self.server_lock_path = self.server_runtime_dir / "locks" / "paper-jars.lock" self.config_path = self.runtime_dir / "config.json" self.state_path = self.runtime_dir / "state.json" self.todo_path = self.runtime_dir / "todo.log" + legacy_runtime_names = ("config.json", "state.json") + self.legacy_runtime_files = [ + self.script_dir / name + for name in legacy_runtime_names + if self.runtime_dir != self.script_dir + and (self.script_dir / name).is_file() + and not (self.runtime_dir / name).exists() + ] + self.validate_server_runtime_path(self.runtime_dir) + if self.runtime_dir.exists() and not self.runtime_dir.is_dir(): + raise PaperScriptError( + f"PaperScript runtime path must be a directory, not {self.runtime_dir}." + ) self.config = self._load_config() self.quiet_mode = bool(args.quiet or self.config.get("quiet")) self.no_color = bool(args.no_color or self.config.get("no_color")) self.color_theme_name = normalize_choice(self.config.get("color_theme"), set(COLOR_THEMES), "default") - self.backups_dir = self.runtime_dir / str(self.config["backup_dir"]) - self.downloads_dir = self.runtime_dir / str(self.config["downloads_dir"]) - self.metadata_cache_dir = self.runtime_dir / str(self.config["metadata_cache_dir"]) - self.log_path = self.runtime_dir / str(self.config["log_file"]) + self.backups_dir = self.configured_runtime_path("backup_dir") + self.downloads_dir = self.configured_runtime_path("downloads_dir") + self.metadata_cache_dir = self.configured_runtime_path("metadata_cache_dir") + self.log_path = self.configured_runtime_path("log_file") + self.validate_runtime_layout() self.logger = Logger( self.log_path, quiet=self.quiet_mode, use_color=supports_color(sys.stdout, no_color=self.no_color), theme_name=self.color_theme_name, ) + if self.legacy_runtime_files: + legacy_paths = ", ".join(str(path) for path in self.legacy_runtime_files) + self.logger.warn( + f"Legacy central-checkout runtime file(s) were not reused for target {self.server_dir}: " + f"{legacy_paths}. Review them and manually copy any target-specific settings or state into " + f"{self.runtime_dir}; PaperScript created safe target-local defaults." + ) ensure_directory(self.backups_dir) ensure_directory(self.downloads_dir) ensure_directory(self.metadata_cache_dir) @@ -857,29 +1587,29 @@ def __init__(self, args: argparse.Namespace) -> None: self.server_name = self.config.get("server_name") self.default_channel = str(self.config["default_channel"]).upper() self.check_latest_channel_only = str(self.config["check_latest_channel_only"]).upper() - self.allow_cross_version_auto_upgrade = bool(self.config["allow_cross_version_auto_upgrade"]) - self.allow_same_version_build_upgrade = bool(self.config["allow_same_version_build_upgrade"]) - self.keep_backups = int(self.config["keep_backups"]) - self.cleanup_backups_after_install = bool(self.config["cleanup_backups_after_install"]) - self.running_server_action = str(self.config["running_server_action"]) - self.graceful_stop_command = str(self.config["graceful_stop_command"]) - self.status_show_all_channels = bool(self.config["status_show_all_channels"]) - self.download_filename_pattern = str(self.config["download_filename_pattern"]) - self.confirm_before_force_download = bool(self.config["confirm_before_force_download"]) - self.confirm_before_downgrade = bool(self.config["confirm_before_downgrade"]) - self.auto_detect_server_by_port = bool(self.config["auto_detect_server_by_port"]) - self.fallback_process_detection = bool(self.config["fallback_process_detection"]) + self.allow_same_version_build_upgrade = self.config["allow_same_version_build_upgrade"] + self.keep_backups = self.config["keep_backups"] + self.keep_server_jars = self.config["keep_server_jars"] + self.keep_archived_jars = self.config["keep_archived_jars"] + self.reconcile_server_jars_after_stage = self.config["reconcile_server_jars_after_stage"] + self.status_show_all_channels = self.config["status_show_all_channels"] + self.confirm_before_force_download = self.config["confirm_before_force_download"] + self.confirm_before_downgrade = self.config["confirm_before_downgrade"] + self.auto_detect_server_by_port = self.config["auto_detect_server_by_port"] + self.fallback_process_detection = self.config["fallback_process_detection"] self.default_status_view = normalize_choice(self.config.get("default_status_view"), {"full", "compact"}, "full") self.command_hint_mode = normalize_choice(self.config.get("command_hint_mode"), {"auto", "always", "never"}, "auto") self.release_link_mode = normalize_choice(self.config.get("release_link_mode"), {"auto", "always", "never"}, "auto") self.debug_http = bool(args.debug_http or self.config.get("debug_http")) - self.metadata_cache_enabled = bool(self.config.get("metadata_cache_enabled", True)) and not bool(args.no_metadata_cache) - self.metadata_cache_ttl_seconds = int(self.config.get("metadata_cache_ttl_seconds", 300)) - self.http_retries = int(self.config.get("http_retries", 2)) - self.http_retry_backoff_seconds = float(self.config.get("http_retry_backoff_seconds", 1.5)) - self.list_versions_channel_delay_ms = int(self.config.get("list_versions_channel_delay_ms", 150)) - self.list_versions_continue_on_error = bool(self.config.get("list_versions_continue_on_error", True)) - self.http_timeout = int(args.timeout) if args.timeout is not None else int(self.config["http_timeout_seconds"]) + self.metadata_cache_enabled = self.config["metadata_cache_enabled"] and not bool(args.no_metadata_cache) + self.metadata_cache_ttl_seconds = self.config["metadata_cache_ttl_seconds"] + self.http_retries = self.config["http_retries"] + self.http_retry_backoff_seconds = float(self.config["http_retry_backoff_seconds"]) + self.list_versions_channel_delay_ms = self.config["list_versions_channel_delay_ms"] + self.list_versions_continue_on_error = self.config["list_versions_continue_on_error"] + self.http_timeout = int(args.timeout) if args.timeout is not None else self.config["http_timeout_seconds"] + if self.http_timeout < 1: + raise PaperScriptError("--timeout must be at least 1 second.") self.user_agent = self._resolve_user_agent() self.api = PaperAPI( self.user_agent, @@ -909,7 +1639,7 @@ def metadata_cache_file_count(self) -> int: return sum(1 for path in self.metadata_cache_dir.iterdir() if path.is_file()) def force_example_for_current(self, current: JarInfo | None) -> str | None: - if current is None: + if current is None or current.build < 0: return None return f"./paperscript.sh --force download --version {current.version} --build {current.build}" @@ -946,38 +1676,279 @@ def log_release_page(self, relevant: bool = False) -> None: self.logger.log(f"Release page: {PAPER_DOWNLOADS_URL}") def format_browser_entry(self, prefix: str, value: str, suffix: str = "") -> str: + prefix = terminal_safe_text(prefix) + value = terminal_safe_text(value) + suffix = terminal_safe_text(suffix) if not self.logger.use_color: return f"{prefix}{value}{suffix}" theme = self.logger.theme rendered_prefix = color_text(prefix, theme["key"], True) rendered_value = color_text(value, theme["value"], True, bold=True) rendered_suffix = color_text(suffix, theme["key"], True) if suffix else "" - return f"{rendered_prefix}{rendered_value}{rendered_suffix}" + return StyledConsoleText(f"{rendered_prefix}{rendered_value}{rendered_suffix}") def _resolve_server_dir(self) -> Path: if self.args.server_dir: - return Path(self.args.server_dir).expanduser().resolve() - cwd = Path.cwd().resolve() - if cwd == self.script_dir and self.script_dir.name.lower() == APP_NAME.lower(): - return self.script_dir.parent.resolve() - return cwd + resolved = Path(self.args.server_dir).expanduser().resolve() + else: + cwd = Path.cwd().resolve() + resolved = ( + self.script_dir.parent.resolve() + if cwd == self.script_dir and self.script_dir.name.lower() == APP_NAME.lower() + else cwd + ) + if resolved == Path(resolved.anchor): + raise PaperScriptError("Refusing to use a filesystem root as the server directory.") + return resolved + + def configured_runtime_path(self, key: str) -> Path: + raw_path = Path(str(self.config[key])) + if raw_path.is_absolute(): + raise PaperScriptError( + f"Config key {key!r} must be relative to {self.runtime_dir}; absolute paths are refused." + ) + if ".." in raw_path.parts: + raise PaperScriptError( + f"Config key {key!r} contains parent traversal; PaperScript runtime paths must stay local." + ) + candidate = self.runtime_dir / raw_path + self.validate_server_runtime_path(candidate) + if candidate.resolve(strict=False) == self.runtime_dir.resolve(strict=False): + raise PaperScriptError( + f"Config key {key!r} cannot target the PaperScript runtime directory itself." + ) + parent = candidate.parent + while parent != self.runtime_dir: + if parent.exists() and not parent.is_dir(): + raise PaperScriptError( + f"Config key {key!r} has a non-directory path component: {parent}." + ) + parent = parent.parent + return candidate + + def validate_runtime_layout(self) -> None: + """Keep cleanup, archive, lock, state, and log roles from aliasing each other.""" + self.validate_server_runtime_path(self.jar_archive_dir) + self.validate_server_runtime_path(self.server_lock_path) + self.validate_server_runtime_path(self.last_launched_jar_marker_path) + + directory_roles = { + "backup_dir": self.backups_dir, + "downloads_dir": self.downloads_dir, + "metadata_cache_dir": self.metadata_cache_dir, + } + directory_items = list(directory_roles.items()) + for index, (left_name, left_path) in enumerate(directory_items): + if left_path.exists() and not left_path.is_dir(): + raise PaperScriptError( + f"Config key {left_name!r} must identify a directory, not {left_path}." + ) + for right_name, right_path in directory_items[index + 1 :]: + if paths_overlap(left_path, right_path): + raise PaperScriptError( + f"Config paths {left_name!r} and {right_name!r} overlap; cleanup roles must be disjoint." + ) + + if paths_overlap(self.backups_dir, self.jar_archive_dir): + if self.backups_dir.resolve(strict=False) != self.jar_archive_dir.parent.resolve(strict=False): + raise PaperScriptError( + "backup_dir may contain the managed JAR archive only as its direct backups/jars child." + ) + for key in ("downloads_dir", "metadata_cache_dir"): + if paths_overlap(directory_roles[key], self.jar_archive_dir): + raise PaperScriptError( + f"Config key {key!r} cannot overlap the managed JAR archive {self.jar_archive_dir}." + ) + + lock_dir = self.server_lock_path.parent + for key, directory in directory_items: + if paths_overlap(directory, lock_dir): + raise PaperScriptError( + f"Config key {key!r} cannot overlap the reserved lock directory {lock_dir}." + ) + + reserved_files = { + self.config_path, + self.state_path, + self.todo_path, + self.last_launched_jar_marker_path, + self.server_runtime_dir / LAUNCHER_MARKER_ROLLBACK, + self.server_lock_path, + } + for key, directory in directory_items: + if any(path_is_within(reserved, directory) for reserved in reserved_files): + raise PaperScriptError( + f"Config key {key!r} contains a reserved PaperScript runtime file." + ) + + if self.log_path.exists() and not self.log_path.is_file(): + raise PaperScriptError(f"Config key 'log_file' must identify a regular file, not {self.log_path}.") + if self.log_path in reserved_files: + raise PaperScriptError("Config key 'log_file' aliases a reserved PaperScript runtime file.") + reserved_directories = [ + self.jar_archive_dir, + lock_dir, + *directory_roles.values(), + ] + if any(paths_overlap(self.log_path, directory) for directory in reserved_directories): + raise PaperScriptError( + "Config key 'log_file' must not be inside a cleanup, archive, or lock directory." + ) def _load_json(self, path: Path) -> dict[str, Any]: if not path.exists(): return {} try: payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return payload if isinstance(payload, dict) else {} + except OSError as error: + raise PaperScriptError(f"Could not read JSON file {path}: {error}") from error + except json.JSONDecodeError as error: + raise PaperScriptError( + f"JSON file {path} is malformed at line {error.lineno}, column {error.colno}; " + "PaperScript left it unchanged." + ) from error + if not isinstance(payload, dict): + raise PaperScriptError(f"JSON file {path} must contain an object; PaperScript left it unchanged.") + return payload def _save_json(self, path: Path, payload: dict[str, Any]) -> None: - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") + + def _validate_config(self, config: dict[str, Any]) -> None: + for key in sorted(BOOLEAN_CONFIG_KEYS): + if type(config.get(key)) is not bool: + raise PaperScriptError( + f"Config key {key!r} must be true or false; PaperScript left config.json unchanged." + ) + for key, minimum in INTEGER_CONFIG_MINIMUMS.items(): + value = config.get(key) + if type(value) is not int or value < minimum: + raise PaperScriptError( + f"Config key {key!r} must be an integer of at least {minimum}; " + "PaperScript left config.json unchanged." + ) + for key, minimum in NUMBER_CONFIG_MINIMUMS.items(): + value = config.get(key) + if ( + type(value) not in {int, float} + or not math.isfinite(float(value)) + or value < minimum + ): + raise PaperScriptError( + f"Config key {key!r} must be a number of at least {minimum:g}; " + "PaperScript left config.json unchanged." + ) + for key in sorted(STRING_CONFIG_KEYS): + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + raise PaperScriptError( + f"Config key {key!r} must be a non-empty string; PaperScript left config.json unchanged." + ) + for key in {"log_file", "backup_dir", "downloads_dir", "metadata_cache_dir"}: + configured_path = Path(str(config[key])) + if configured_path.is_absolute() or ".." in configured_path.parts: + raise PaperScriptError( + f"Config key {key!r} must be a relative path without parent traversal; " + "PaperScript left config.json unchanged." + ) + validation_root = Path("/__paperscript_runtime_layout__") + validation_archive = validation_root / "backups" / "jars" + validation_lock_dir = validation_root / "locks" + validation_directories = { + "backup_dir": validation_root / str(config["backup_dir"]), + "downloads_dir": validation_root / str(config["downloads_dir"]), + "metadata_cache_dir": validation_root / str(config["metadata_cache_dir"]), + } + validation_items = list(validation_directories.items()) + for index, (left_name, left_path) in enumerate(validation_items): + for right_name, right_path in validation_items[index + 1 :]: + if paths_overlap(left_path, right_path): + raise PaperScriptError( + f"Config paths {left_name!r} and {right_name!r} overlap; " + "PaperScript left config.json unchanged." + ) + validation_backup = validation_directories["backup_dir"] + if paths_overlap(validation_backup, validation_archive): + if validation_backup != validation_archive.parent: + raise PaperScriptError( + "Config key 'backup_dir' conflicts with the managed JAR archive; " + "PaperScript left config.json unchanged." + ) + for key in ("downloads_dir", "metadata_cache_dir"): + if paths_overlap(validation_directories[key], validation_archive): + raise PaperScriptError( + f"Config key {key!r} conflicts with the managed JAR archive; " + "PaperScript left config.json unchanged." + ) + for key, directory in validation_items: + if paths_overlap(directory, validation_lock_dir): + raise PaperScriptError( + f"Config key {key!r} conflicts with the reserved lock directory; " + "PaperScript left config.json unchanged." + ) + validation_log = validation_root / str(config["log_file"]) + validation_reserved_files = { + validation_root / "config.json", + validation_root / "state.json", + validation_root / "todo.log", + validation_root / LAST_LAUNCHED_JAR_MARKER, + validation_root / LAUNCHER_MARKER_ROLLBACK, + validation_lock_dir / "paper-jars.lock", + } + for key, directory in validation_items: + if any( + path_is_within(reserved, directory) + for reserved in validation_reserved_files + ): + raise PaperScriptError( + f"Config key {key!r} contains a reserved runtime file; " + "PaperScript left config.json unchanged." + ) + if validation_log in validation_reserved_files or any( + paths_overlap(validation_log, directory) + for directory in [validation_archive, validation_lock_dir, *validation_directories.values()] + ): + raise PaperScriptError( + "Config key 'log_file' conflicts with a cleanup, archive, lock, or reserved-file role; " + "PaperScript left config.json unchanged." + ) + for key in sorted(OPTIONAL_STRING_CONFIG_KEYS): + value = config.get(key) + if value is not None and not isinstance(value, str): + raise PaperScriptError( + f"Config key {key!r} must be a string or null; PaperScript left config.json unchanged." + ) + for key, choices in CONFIG_CHOICES.items(): + value = config.get(key) + if not isinstance(value, str): + expected = ", ".join(sorted(choices)) + raise PaperScriptError( + f"Config key {key!r} must be one of {expected}; PaperScript left config.json unchanged." + ) + normalized = value.upper() if key in {"default_channel", "check_latest_channel_only"} else value.lower() + normalized_choices = { + choice.upper() if key in {"default_channel", "check_latest_channel_only"} else choice.lower() + for choice in choices + } + if normalized not in normalized_choices: + expected = ", ".join(sorted(choices)) + raise PaperScriptError( + f"Config key {key!r} must be one of {expected}; PaperScript left config.json unchanged." + ) def _load_config(self) -> dict[str, Any]: raw = self._load_json(self.config_path) + migrated = {key: value for key, value in raw.items() if key not in DEPRECATED_CONFIG_KEYS} + allowed_keys = set(DEFAULT_CONFIG) | OPTIONAL_STRING_CONFIG_KEYS + unknown_keys = sorted(set(migrated) - allowed_keys) + if unknown_keys: + rendered = ", ".join(repr(key) for key in unknown_keys) + raise PaperScriptError( + f"Unknown config key(s): {rendered}. PaperScript left config.json unchanged." + ) merged = dict(DEFAULT_CONFIG) - merged.update(raw) + merged.update(migrated) + self._validate_config(merged) if raw != merged: self._save_json(self.config_path, merged) return merged @@ -1004,29 +1975,56 @@ def _resolve_user_agent(self) -> str: return DEFAULT_USER_AGENT - def record_state(self, build: BuildInfo, installed_path: Path, current_sha256: str) -> None: + def record_state(self, build: BuildInfo, staged_path: Path, current_sha256: str) -> None: self.state.update( { - "current_build": build.build_id, - "current_channel": build.channel, - "current_jar": installed_path.name, - "current_version": build.version, - "installed_at": utc_now(), + "staged_build": build.build_id, + "staged_channel": build.channel, + "staged_jar": staged_path.name, + "staged_version": build.version, + "staged_at": utc_now(), + "staged_sha256": current_sha256, "server_dir": str(self.server_dir), "expected_sha256": build.sha256, - "current_sha256": current_sha256, "download_url": build.download_url, } ) self._save_json(self.state_path, self.state) + def recorded_staged_jar(self, version: str) -> Path | None: + state_name = self.state.get("staged_jar") + state_version = self.state.get("staged_version") + state_server_dir = self.state.get("server_dir") + if not state_name or not state_version: + return None + if str(state_version).casefold() != version.casefold(): + return None + if state_server_dir: + try: + if Path(str(state_server_dir)).resolve() != self.server_dir.resolve(): + return None + except OSError: + return None + path = self.server_dir / str(state_name) + if self.strict_managed_jar_info(path, version) is None: + return None + expected = self.state.get("staged_sha256") or self.state.get("expected_sha256") + if expected and re.fullmatch(r"[0-9a-fA-F]{64}", str(expected)): + if sha256_file(path).lower() != str(expected).lower(): + raise PaperScriptError( + f"Recorded staged jar {path.name} no longer matches its recorded SHA-256; cleanup was refused." + ) + return path + def jar_info_from_state(self, path: Path) -> JarInfo | None: - state_name = self.state.get("current_jar") + state_name = self.state.get("staged_jar") or self.state.get("current_jar") if not state_name or path.name != str(state_name): return None - version = self.state.get("current_version") - build = self.state.get("current_build") + version = self.state.get("staged_version") or self.state.get("current_version") + build = self.state.get("staged_build") + if build is None: + build = self.state.get("current_build") if not version or build is None: return None @@ -1044,52 +2042,490 @@ def jar_info_from_filename(self, path: Path) -> JarInfo | None: return self.jar_info_from_state(path) def find_current_jar(self) -> JarInfo | None: - state_name = self.state.get("current_jar") - if state_name: - state_path = self.server_dir / state_name - if state_path.exists(): - state_jar = self.jar_info_from_filename(state_path) - if state_jar: - return state_jar - candidates: list[JarInfo] = [] for path in self.server_dir.glob("*.jar"): + if path.is_symlink() or not path.is_file(): + continue jar = self.jar_info_from_filename(path) - if jar: + if jar and CURRENT_JAR_PATTERN.fullmatch(path.name): candidates.append(jar) - if not candidates: - return None + if candidates: + candidates.sort( + key=lambda item: (parse_version(item.version).key(), item.build), + reverse=True, + ) + return candidates[0] - candidates.sort( - key=lambda item: (parse_version(item.version).key(), item.build), - reverse=True, - ) - return candidates[0] + # Compatibility fallback for a legacy/custom non-numeric jar explicitly recorded in state. + state_name = self.state.get("staged_jar") or self.state.get("current_jar") + if state_name: + state_path = self.server_dir / str(state_name) + if state_path.parent == self.server_dir and not state_path.is_symlink() and state_path.is_file(): + return self.jar_info_from_state(state_path) + return None - def latest_stable_version(self) -> tuple[str, BuildInfo]: - versions = [item["id"] for item in self.api.get_project_versions()] - for version in versions: - build = self.api.get_latest_build(version, channel=self.check_latest_channel_only) - if build: - return version, build - raise PaperScriptError("No stable Paper builds were found.") + @contextmanager + def server_mutation_lock(self) -> Iterator[None]: + self.validate_server_runtime_path(self.server_lock_path) + with ServerMutationLock(self.server_lock_path): + yield - def latest_version_for_channel(self, channel: str) -> tuple[str, BuildInfo]: - channel_upper = channel.upper() - versions = [item["id"] for item in self.api.get_project_versions()] - for version in versions: - build = self.api.get_latest_build(version, channel=channel_upper) - if build: - return version, build - raise PaperScriptError(f"No {channel_upper} Paper builds were found.") + def validate_server_runtime_path(self, path: Path) -> None: + """Reject retention paths that escape the target server through traversal or symlinks.""" + server_root = self.server_dir.resolve() + candidate = path.resolve(strict=False) + try: + candidate.relative_to(server_root) + except ValueError as error: + raise PaperScriptError( + f"Refusing to use a PaperScript server-runtime path outside {server_root}: {path}" + ) from error - def latest_preview_version(self, stable_version: str) -> tuple[str, BuildInfo] | None: - versions = [item["id"] for item in self.api.get_project_versions()] - for version in versions: - if compare_versions(version, stable_version) <= 0: - continue - for channel in ["BETA", "ALPHA"]: - build = self.api.get_latest_build(version, channel=channel) + current = path + while current != self.server_dir: + if current.is_symlink(): + raise PaperScriptError( + f"Refusing to use symlinked server-runtime path component: {current}" + ) + parent = current.parent + if parent == current: + raise PaperScriptError(f"Could not contain server-runtime path {path} inside {server_root}.") + current = parent + + def strict_managed_jar_info(self, path: Path, version: str | None = None) -> JarInfo | None: + """Return canonical numeric Paper metadata without following symlinks.""" + if path.parent != self.server_dir or path.is_symlink(): + return None + try: + metadata = path.lstat() + except OSError: + return None + if not stat.S_ISREG(metadata.st_mode): + return None + match = CURRENT_JAR_PATTERN.fullmatch(path.name) + if not match: + return None + parsed_version = match.group(1) + if not SAFE_VERSION_PATTERN.fullmatch(parsed_version): + return None + if version is not None and parsed_version.casefold() != version.casefold(): + return None + return JarInfo(path=path, version=parsed_version, build=int(match.group(2))) + + def managed_server_jars(self, version: str) -> list[JarInfo]: + if not SAFE_VERSION_PATTERN.fullmatch(version): + raise PaperScriptError(f"Unsafe Minecraft version for jar retention: {version!r}") + candidates: list[JarInfo] = [] + try: + children = list(self.server_dir.iterdir()) + except OSError as error: + raise PaperScriptError(f"Could not inspect server directory {self.server_dir}: {error}") from error + for path in children: + jar = self.strict_managed_jar_info(path, version) + if jar is not None: + candidates.append(jar) + candidates.sort(key=lambda item: (item.build, item.path.name.casefold(), item.path.name), reverse=True) + return candidates + + def launcher_jar_selection(self) -> LauncherJarSelection: + """Read the launcher's exact selection without authorizing legacy jars for retention.""" + marker = self.last_launched_jar_marker_path + self.validate_server_runtime_path(marker) + if marker.is_symlink() or not marker.is_file(): + raise PaperScriptError( + f"No valid launcher marker exists at {marker}. " + "Start the server once with the updated 1MB-minecraft.sh, then retry." + ) + try: + if marker.stat().st_size > 512: + raise PaperScriptError(f"Launcher marker is unexpectedly large: {marker}") + raw_name = marker.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise PaperScriptError(f"Could not read launcher marker {marker}: {error}") from error + name = raw_name.strip() + if ( + not name + or name != Path(name).name + or "/" in name + or "\\" in name + or len(raw_name.splitlines()) != 1 + ): + raise PaperScriptError(f"Launcher marker does not contain one safe jar basename: {marker}") + path = self.server_dir / name + if path.is_symlink(): + raise PaperScriptError(f"Launcher marker identifies a symlink, which is unsafe: {path}") + try: + metadata = path.lstat() + except OSError as error: + raise PaperScriptError(f"Launcher marker identifies a missing or unreadable jar: {path}") from error + if not stat.S_ISREG(metadata.st_mode): + raise PaperScriptError(f"Launcher marker does not identify a regular file: {path}") + + numeric = self.strict_managed_jar_info(path) + if numeric is not None: + return LauncherJarSelection(numeric.path, numeric.version, numeric.build) + + legacy_match = LEGACY_JAR_PATTERN.fullmatch(name) + if legacy_match and SAFE_VERSION_PATTERN.fullmatch(legacy_match.group(1)): + return LauncherJarSelection(path, legacy_match.group(1), None) + raise PaperScriptError( + f"Launcher marker {marker} does not identify an existing regular Paper jar with a safe version name." + ) + + def last_launched_jar(self, version: str | None = None) -> JarInfo: + selection = self.launcher_jar_selection() + if version is not None and selection.version.casefold() != version.casefold(): + expected = f"Paper-{version}-.jar" + raise PaperScriptError( + f"Launcher marker {self.last_launched_jar_marker_path} does not identify {expected}. " + "Root jar retention was skipped." + ) + if selection.build is None: + raise PaperScriptError( + f"Launcher marker identifies legacy jar {selection.path.name}. Root jar retention requires " + "a numeric Paper--.jar marker and was skipped. Stage a numeric build, " + "then start it once with the updated 1MB-minecraft.sh." + ) + return JarInfo(selection.path, selection.version, selection.build) + + def inflight_launcher_rollback_jar(self, version: str) -> JarInfo | None: + """Protect the prior numeric JAR while the launcher may need to roll back its marker.""" + launch_lock = self.server_runtime_dir / "locks" / "server-launch" + rollback_marker = self.server_runtime_dir / LAUNCHER_MARKER_ROLLBACK + self.validate_server_runtime_path(launch_lock) + self.validate_server_runtime_path(rollback_marker) + if not launch_lock.exists(): + return None + if launch_lock.is_symlink() or not launch_lock.is_dir(): + raise PaperScriptError( + f"Active launcher lock is unsafe; root JAR retention was refused: {launch_lock}" + ) + if not rollback_marker.exists(): + return None + if rollback_marker.is_symlink() or not rollback_marker.is_file(): + raise PaperScriptError( + f"Active launcher rollback marker is unsafe; root JAR retention was refused: {rollback_marker}" + ) + try: + if rollback_marker.stat().st_size > 512: + raise PaperScriptError( + f"Active launcher rollback marker is unexpectedly large: {rollback_marker}" + ) + raw_name = rollback_marker.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise PaperScriptError( + f"Could not read active launcher rollback marker {rollback_marker}: {error}" + ) from error + name = raw_name.strip() + if not name: + return None + if ( + name != Path(name).name + or "/" in name + or "\\" in name + or len(raw_name.splitlines()) != 1 + ): + raise PaperScriptError( + f"Active launcher rollback marker does not contain one safe JAR basename: {rollback_marker}" + ) + path = self.server_dir / name + managed = self.strict_managed_jar_info(path, version) + if managed is not None: + return managed + + numeric = CURRENT_JAR_PATTERN.fullmatch(name) + if numeric and numeric.group(1).casefold() == version.casefold(): + raise PaperScriptError( + f"Active launcher rollback JAR is missing or unsafe: {path}. Root JAR retention was refused." + ) + return None + + def plan_server_jar_retention( + self, + version: str, + keep: int | None = None, + staged_path: Path | None = None, + ) -> JarRetentionPlan: + keep_count = self.keep_server_jars if keep is None else int(keep) + if keep_count < 2: + raise PaperScriptError( + "Server-root jar retention must keep at least two slots: last launched plus newest staged." + ) + last_launched = self.last_launched_jar(version) + candidates = self.managed_server_jars(version) + protected: list[Path] = [last_launched.path] + launch_rollback = self.inflight_launcher_rollback_jar(version) + if launch_rollback is not None and launch_rollback.path not in protected: + protected.append(launch_rollback.path) + staged: JarInfo | None = None + if staged_path is not None: + staged = self.strict_managed_jar_info(staged_path, version) + if staged is None: + raise PaperScriptError(f"Just-staged jar is no longer a safe regular file: {staged_path}") + if candidates and candidates[0].path not in protected: + protected.append(candidates[0].path) + if staged is not None and candidates and staged.path != candidates[0].path: + self.logger.log( + f"Recorded staged jar {staged.path.name} is not the greatest numeric build; " + f"the launcher will choose {candidates[0].path.name}, so only the actual next selection is protected." + ) + for jar in candidates: + if ( + keep_count > 2 + and jar.path not in protected + and len(protected) < keep_count + ): + protected.append(jar.path) + protected_set = set(protected) + to_archive = tuple(jar.path for jar in candidates if jar.path not in protected_set) + to_prune = self.predict_managed_archive_prune(version, to_archive) + return JarRetentionPlan( + version=version, + last_launched=last_launched.path, + kept=tuple(protected), + to_archive=to_archive, + to_prune=to_prune, + launch_rollback=launch_rollback.path if launch_rollback is not None else None, + ) + + def archive_directory_for_version(self, version: str) -> Path: + if not SAFE_VERSION_PATTERN.fullmatch(version): + raise PaperScriptError(f"Unsafe Minecraft version for jar archive: {version!r}") + path = self.jar_archive_dir / version + self.validate_server_runtime_path(path) + return path + + def fsync_archive_publish_chain(self, archive_dir: Path) -> None: + """Require every directory entry from the version archive through the server root to be durable.""" + current = archive_dir + while True: + fsync_directory(current, strict=True) + if current == self.server_dir: + return + if current == current.parent: + raise PaperScriptError( + f"Could not contain archive directory {archive_dir} under {self.server_dir}." + ) + current = current.parent + + def archive_server_jar(self, source: Path, version: str) -> Path: + jar = self.strict_managed_jar_info(source, version) + if jar is None: + raise PaperScriptError(f"Refusing to archive a changed or unmanaged jar: {source}") + archive_dir = self.archive_directory_for_version(version) + ensure_directory(archive_dir) + self.validate_server_runtime_path(archive_dir) + destination = archive_dir / source.name + + if destination.is_symlink() or (destination.exists() and not destination.is_file()): + raise PaperScriptError(f"Refusing to overwrite non-file jar archive destination: {destination}") + if destination.exists(): + before = source.lstat() + source_sha = sha256_file(source) + after = source.lstat() + if ( + source.is_symlink() + or (before.st_dev, before.st_ino, before.st_size) + != (after.st_dev, after.st_ino, after.st_size) + ): + raise PaperScriptError( + f"Jar changed while an archive collision was checked; left both paths unchanged: {source}" + ) + if sha256_file(destination) != source_sha: + raise PaperScriptError( + f"Jar archive collision has different content; left both paths unchanged: {destination}" + ) + self.fsync_archive_publish_chain(archive_dir) + source.unlink() + fsync_directory(self.server_dir) + self.logger.log(f"Removed duplicate root jar already present in the archive: {source.name}") + return destination + + before = source.lstat() + descriptor, temporary_name = tempfile.mkstemp( + dir=archive_dir, + prefix=f".{source.name}.", + suffix=".part", + ) + temporary = Path(temporary_name) + try: + digest = hashlib.sha256() + with source.open("rb") as input_handle, os.fdopen(descriptor, "wb") as output_handle: + descriptor = -1 + while True: + chunk = input_handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + output_handle.write(chunk) + output_handle.flush() + os.fsync(output_handle.fileno()) + after = source.lstat() + if ( + source.is_symlink() + or (before.st_dev, before.st_ino, before.st_size) + != (after.st_dev, after.st_ino, after.st_size) + or digest.hexdigest() != sha256_file(temporary) + ): + raise PaperScriptError(f"Jar changed while it was being archived; left the root copy in place: {source}") + try: + os.link(temporary, destination) + except FileExistsError as error: + raise PaperScriptError(f"Jar archive destination appeared during cleanup: {destination}") from error + temporary.unlink() + self.fsync_archive_publish_chain(archive_dir) + source.unlink() + fsync_directory(self.server_dir) + self.logger.log(f"Archived old server-root jar: {source.name} -> {destination}") + return destination + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary.exists(): + temporary.unlink() + + def predict_managed_archive_prune( + self, + version: str, + incoming: tuple[Path, ...] = (), + ) -> tuple[Path, ...]: + archive_dir = self.archive_directory_for_version(version) + archived: list[JarInfo] = [] + if archive_dir.exists(): + for path in archive_dir.iterdir(): + if path.is_symlink(): + continue + try: + metadata = path.lstat() + except OSError: + continue + if not stat.S_ISREG(metadata.st_mode): + continue + match = CURRENT_JAR_PATTERN.fullmatch(path.name) + if not match or match.group(1).casefold() != version.casefold(): + continue + archived.append(JarInfo(path, match.group(1), int(match.group(2)))) + known_paths = {item.path for item in archived} + for source in incoming: + match = CURRENT_JAR_PATTERN.fullmatch(source.name) + if not match or match.group(1).casefold() != version.casefold(): + raise PaperScriptError(f"Cannot predict archive retention for unmanaged jar: {source}") + destination = archive_dir / source.name + if destination in known_paths or destination.exists(): + continue + archived.append(JarInfo(destination, match.group(1), int(match.group(2)))) + known_paths.add(destination) + archived.sort(key=lambda item: (item.build, item.path.name.casefold(), item.path.name), reverse=True) + return tuple(jar.path for jar in archived[self.keep_archived_jars :]) + + def prune_managed_jar_archive( + self, + version: str, + expected: tuple[Path, ...] | None = None, + ) -> int: + archive_dir = self.archive_directory_for_version(version) + to_prune = self.predict_managed_archive_prune(version) + if expected is not None and to_prune != expected: + raise PaperScriptError( + "Managed JAR archive contents changed after retention was planned; no archive files were pruned." + ) + removed = 0 + for path in to_prune: + if path.is_symlink() or not path.is_file(): + raise PaperScriptError(f"Refusing to prune a changed managed JAR archive path: {path}") + match = CURRENT_JAR_PATTERN.fullmatch(path.name) + if not match or match.group(1).casefold() != version.casefold(): + raise PaperScriptError(f"Refusing to prune an unmanaged JAR archive path: {path}") + path.unlink() + removed += 1 + self.logger.log(f"Pruned archived Paper jar: {path}") + if removed: + fsync_directory(archive_dir) + return removed + + def managed_jar_archive_count(self, version: str) -> int: + archive_dir = self.archive_directory_for_version(version) + if not archive_dir.exists(): + return 0 + count = 0 + for path in archive_dir.iterdir(): + match = CURRENT_JAR_PATTERN.fullmatch(path.name) + if ( + not path.is_symlink() + and path.is_file() + and match + and match.group(1).casefold() == version.casefold() + ): + count += 1 + return count + + def apply_server_jar_retention(self, plan: JarRetentionPlan, dry_run: bool = False) -> int: + current_marker = self.last_launched_jar(plan.version) + if current_marker.path != plan.last_launched: + raise PaperScriptError("The last-launched jar changed while retention was being planned; retry cleanup.") + if dry_run: + for source in plan.to_archive: + destination = self.archive_directory_for_version(plan.version) / source.name + self.logger.log(f"Dry run: would archive {source.name} to {destination}") + for path in plan.to_prune: + self.logger.log( + f"Dry run: would permanently prune archived Paper jar beyond the configured cap: {path}" + ) + return 0 + + archived = 0 + for source in plan.to_archive: + self.archive_server_jar(source, plan.version) + archived += 1 + self.prune_managed_jar_archive(plan.version, expected=plan.to_prune) + return archived + + def latest_stable_version(self, *, use_cache: bool = True) -> tuple[str, BuildInfo]: + return self.latest_version_for_channel( + self.check_latest_channel_only, + use_cache=use_cache, + ) + + def latest_version_for_channel( + self, + channel: str, + *, + use_cache: bool = True, + ) -> tuple[str, BuildInfo]: + channel_upper = channel.upper() + versions = [ + item["id"] + for item in self.api.get_project_versions(use_cache=use_cache) + ] + for version in versions: + build = self.api.get_latest_build( + version, + channel=channel_upper, + use_cache=use_cache, + ) + if build: + return version, build + raise PaperScriptError(f"No {channel_upper} Paper builds were found.") + + def latest_preview_version( + self, + stable_version: str, + *, + use_cache: bool = True, + ) -> tuple[str, BuildInfo] | None: + versions = [ + item["id"] + for item in self.api.get_project_versions(use_cache=use_cache) + ] + for version in versions: + if compare_versions(version, stable_version) <= 0: + continue + for channel in ["BETA", "ALPHA"]: + build = self.api.get_latest_build( + version, + channel=channel, + use_cache=use_cache, + ) if build: return version, build return None @@ -1103,11 +2539,11 @@ def describe_server_context(self) -> None: self.logger.log(f"Server properties found: {'yes' if has_server_properties else 'no'}") if current_jar: self.logger.log( - f"Detected current jar: {current_jar.path.name} " + f"Detected newest managed jar: {current_jar.path.name} " f"(version {current_jar.version}, build {current_jar.build})" ) else: - self.logger.log("Detected current jar: none") + self.logger.log("Detected newest managed jar: none") self.log_command_hint( "For a stable overview, run './paperscript.sh stable'. For a preview overview, run './paperscript.sh experimental'." ) @@ -1210,10 +2646,12 @@ def inspect_version(self, version: str, offer_download: bool = True) -> None: default="stable" if "STABLE" in by_channel else None, logger=self.logger, ) - self.install_build( + self.stage_build( by_channel[selected.upper()], force_version_prompt=True, - prompt_for_force_reinstall=True, + prompt_for_forced_recheck=True, + selection_policy=STAGE_SELECTION_LATEST_CHANNEL, + required_channel=selected.upper(), ) def explore_versions(self) -> None: @@ -1232,128 +2670,101 @@ def explore_versions(self) -> None: return self.console_only("Please enter one of the listed numbers.") - def choose_target_for_update(self) -> BuildInfo | None: - current = self.find_current_jar() - latest_version, latest_build = self.latest_stable_version() + def choose_target_for_update(self, *, use_cache: bool = True) -> UpdateSelection | None: + newest_managed = self.find_current_jar() + try: + current = self.last_launched_jar() + update_basis = "launcher-marked" + except PaperScriptError: + try: + launcher_selection = self.launcher_jar_selection() + except PaperScriptError: + current = newest_managed + update_basis = "newest managed (launcher marker unavailable)" + else: + same_family = self.managed_server_jars(launcher_selection.version) + state_jar = self.jar_info_from_state(launcher_selection.path) + current = ( + same_family[0] + if same_family + else state_jar + if state_jar is not None + else JarInfo(launcher_selection.path, launcher_selection.version, -1) + ) + update_basis = "launcher-marked legacy family" + latest_overall_version, latest_overall_build = self.latest_stable_version( + use_cache=use_cache, + ) if current is None: self.logger.log( - f"No current Paper jar detected. Latest stable is version {latest_version} build #{latest_build.build_id}." + f"No managed Paper jar detected. Latest stable is version {latest_overall_version} " + f"build #{latest_overall_build.build_id}." + ) + return UpdateSelection( + latest_overall_build, + STAGE_SELECTION_LATEST_OVERALL, ) - return latest_build - version_cmp = compare_versions(current.version, latest_version) - if version_cmp == 0: - if latest_build.build_id > current.build: - if not self.allow_same_version_build_upgrade: - self.logger.log( - "A newer build exists for the current version, but same-version build upgrades are disabled in config." - ) - return None + if current.version.casefold() == latest_overall_version.casefold(): + latest_build = latest_overall_build + else: + latest_build = self.api.get_latest_build( + current.version, + channel=self.check_latest_channel_only, + use_cache=use_cache, + ) + if latest_build is None: self.logger.log( - f"Current server is on {current.version} build #{current.build}. " - f"Latest stable build is #{latest_build.build_id}." + f"No {self.check_latest_channel_only} build was found for the selected Minecraft version " + f"{current.version}; no download was performed." ) - return latest_build - if latest_build.build_id == current.build and self.args.force: + return None + if compare_versions(latest_overall_version, current.version) > 0: self.logger.log( - f"Current server is already on {current.version} build #{current.build}, " - "but --force was supplied, so PaperScript will re-download and reinstall the latest stable build." + f"A newer Minecraft family ({latest_overall_version}) exists, but update only stages builds " + f"for the {update_basis} version {current.version}. Use an explicit download --version " + "command after reviewing launcher/plugin compatibility." ) - return latest_build - self.logger.log( - f"Current server is already on {current.version} build #{current.build}. " - "No newer stable build is available, so no download was performed." - ) - self.logger.log("If you want to re-download this jar anyway, run one of these:") - self.logger.log(" ./paperscript.sh --force update") - exact_force = self.force_example_for_current(current) - if exact_force: - self.logger.log(f" {exact_force}") - return None - if version_cmp > 0: + if latest_build.build_id > current.build: + if not self.allow_same_version_build_upgrade: + self.logger.log( + "A newer build exists for the selected version, but same-version build upgrades are disabled in config." + ) + return None + current_description = ( + f"{current.version} build #{current.build}" + if current.build >= 0 + else f"{current.version} with an unknown legacy build" + ) self.logger.log( - f"Current server version {current.version} is newer than the latest stable version " - f"this script found ({latest_version}). No download was performed automatically." + f"The {update_basis} server family is {current_description}. " + f"Latest stable build for that version is #{latest_build.build_id}." ) - return None - - self.logger.log( - f"Current server is on version {current.version} build #{current.build}. " - f"Latest stable is {latest_version} build #{latest_build.build_id}." - ) - if self.allow_cross_version_auto_upgrade: - self.logger.log("Cross-version auto-upgrade is enabled in config, so PaperScript will continue.") - return latest_build - if self.args.dry_run: + return UpdateSelection( + latest_build, + STAGE_SELECTION_LATEST_CHANNEL, + ) + if latest_build.build_id == current.build and self.args.force: self.logger.log( - f"Dry run: PaperScript would ask before upgrading from {current.version} to {latest_version}." + f"The {update_basis} server family already has {current.version} build #{current.build}, " + "but --force was supplied, so PaperScript will re-check that build." ) - return latest_build - if self.args.yes or prompt_yes_no( - f"This is a version upgrade from {current.version} to {latest_version}. Download it?", - default=False, - logger=self.logger, - ): - return latest_build - self.logger.log("Skipped version upgrade by choice.") - self.logger.log("No download was performed.") - return None - - def ensure_safe_to_upgrade(self) -> None: - if not (self.server_dir / "server.properties").exists(): - return - processes = self.detect_running_server_processes() - if not processes: - return - - self.logger.log("A Paper server process appears to be running in this server directory.") - for pid, command in processes: - self.logger.log(f" - PID {pid}: {command}") - - if self.args.dry_run: - action = self.planned_running_server_action() - self.logger.log(f"Dry run: PaperScript would handle the running server with action '{action}'.") - return - - if self.args.yes: - self.logger.log("--yes was supplied, so PaperScript will try a graceful stop automatically.") - self.graceful_stop(processes) - return - - configured_action = self.running_server_action - if configured_action == "graceful-stop": - self.graceful_stop(processes) - return - if configured_action == "force-stop": - self.force_stop(processes) - return - if configured_action == "upgrade-anyway": - self.logger.log("Config is set to continue even if the server appears to still be running.") - return - - choice = prompt_choice( - "Choose how to continue:", - [ - ("g", "Gracefully stop the server first"), - ("f", "Force stop the server"), - ("u", "Upgrade anyway without stopping"), - ("e", "Exit without changing anything"), - ], - default="e", - logger=self.logger, + return UpdateSelection( + latest_build, + STAGE_SELECTION_LATEST_CHANNEL, + ) + self.logger.log( + f"The {update_basis} server family already has {current.version} build #{current.build}. " + "No newer stable build is available, so no download was performed." ) - if choice == "g": - self.graceful_stop(processes) - return - if choice == "f": - self.force_stop(processes) - return - if choice == "u": - self.logger.log("Proceeding even though the server appears to still be running.") - return - raise PaperScriptError("Stopped at user request.") + self.logger.log("If you want to re-check this jar anyway, run one of these:") + self.logger.log(" ./paperscript.sh --force update") + exact_force = self.force_example_for_current(current) + if exact_force: + self.logger.log(f" {exact_force}") + return None def detect_running_server_processes(self) -> list[tuple[int, str]]: if self.auto_detect_server_by_port: @@ -1423,13 +2834,6 @@ def detect_processes_by_server_port(self) -> list[tuple[int, str]]: matches.append((current_pid, current_command or "java")) return matches - def planned_running_server_action(self) -> str: - if self.args.yes: - return "graceful-stop" - if self.running_server_action in {"ask", "graceful-stop", "force-stop", "upgrade-anyway"}: - return self.running_server_action - return "ask" - def process_cwd(self, pid: int) -> str | None: result = run_command(["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"]) if result.returncode != 0: @@ -1439,147 +2843,341 @@ def process_cwd(self, pid: int) -> str | None: return line[1:] return None - def graceful_stop(self, processes: list[tuple[int, str]]) -> None: - if self.try_tmux_stop(): - if self.wait_for_exit([pid for pid, _ in processes], timeout_seconds=45): - self.logger.log("Server stopped after sending the tmux stop command.") - return - self.logger.log("The tmux stop command was sent, but the process is still running.") - - for pid, _ in processes: - os.kill(pid, signal.SIGTERM) - self.logger.log(f"Sent SIGTERM to PID {pid}.") - if not self.wait_for_exit([pid for pid, _ in processes], timeout_seconds=20): - raise PaperScriptError("The server did not stop after a soft shutdown attempt.") - self.logger.log("Server stopped.") - - def force_stop(self, processes: list[tuple[int, str]]) -> None: - for pid, _ in processes: - os.kill(pid, signal.SIGKILL) - self.logger.log(f"Sent SIGKILL to PID {pid}.") - if not self.wait_for_exit([pid for pid, _ in processes], timeout_seconds=10): - raise PaperScriptError("A server process still appears to be running after SIGKILL.") - self.logger.log("Server force-stopped.") - - def wait_for_exit(self, pids: list[int], timeout_seconds: int) -> bool: - deadline = time.time() + timeout_seconds - remaining = set(pids) - while remaining and time.time() < deadline: - finished: list[int] = [] - for pid in remaining: - try: - os.kill(pid, 0) - except OSError: - finished.append(pid) - for pid in finished: - remaining.discard(pid) - if remaining: - time.sleep(1) - return not remaining - - def try_tmux_stop(self) -> bool: - session = self.tmux_session - has_session = run_command(["tmux", "has-session", "-t", session]) - if has_session.returncode != 0: - self.logger.log( - f"tmux session '{session}' was not found, so PaperScript cannot send a graceful stop command there." - ) - return False - - send = run_command(["tmux", "send-keys", "-t", session, self.graceful_stop_command, "Enter"]) - if send.returncode != 0: - self.logger.log( - f"Sending '{self.graceful_stop_command}' to tmux session '{session}' failed: " - f"{send.stderr.strip() or 'unknown error'}" + def stage_target_path(self, build: BuildInfo) -> Path: + if not SAFE_VERSION_PATTERN.fullmatch(build.version): + raise PaperScriptError(f"Unsafe Minecraft version for staging: {build.version!r}") + target_name = build.filename + match = CURRENT_JAR_PATTERN.fullmatch(target_name) + if ( + not match + or match.group(1).casefold() != build.version.casefold() + or int(match.group(2)) != build.build_id + ): + raise PaperScriptError( + f"Safe staging target must be Paper-{build.version}-{build.build_id}.jar." ) - return False + return self.server_dir / target_name - self.logger.log(f"Sent '{self.graceful_stop_command}' to tmux session '{session}'.") - return True - - def backup_existing_jar(self, current: JarInfo | None, incoming_name: str) -> None: - if current and current.path.exists(): - destination = self.backups_dir / f"{timestamp_for_filename()}__{current.path.name}" - shutil.move(str(current.path), str(destination)) - self.logger.log(f"Backed up current jar to {destination}") + @contextmanager + def private_staging_file( + self, + target_path: Path, + expected_size: int, + ) -> Iterator[tuple[int, Path]]: + """Create one private same-filesystem inode and only clean the same identity.""" + descriptor = -1 + temp_path: Path | None = None + staging_identity: tuple[int, int] | None = None + try: + try: + descriptor, temporary_name = tempfile.mkstemp( + dir=self.server_dir, + prefix=f".{target_path.name}.", + suffix=".part", + ) + temp_path = Path(temporary_name) + descriptor_stat = os.fstat(descriptor) + staging_identity = (descriptor_stat.st_dev, descriptor_stat.st_ino) + os.fchmod(descriptor, 0o600) + except OSError as error: + raise PaperScriptError( + f"Staging preflight could not create a private temporary file in " + f"{self.server_dir}: {error}" + ) from error - incoming_path = self.server_dir / incoming_name - if incoming_path.exists(): - destination = self.backups_dir / f"{timestamp_for_filename()}__{incoming_path.name}" - shutil.move(str(incoming_path), str(destination)) - self.logger.log(f"Backed up existing target jar to {destination}") + if not stat.S_ISREG(descriptor_stat.st_mode): + raise PaperScriptError( + f"Staging preflight did not create a regular temporary file in {self.server_dir}." + ) - def prune_old_backups(self) -> None: - if not self.cleanup_backups_after_install or self.keep_backups < 0: - return + try: + free_bytes = shutil.disk_usage(self.server_dir).free + except OSError as error: + raise PaperScriptError( + f"Staging preflight could not determine free disk space for {self.server_dir}: {error}" + ) from error + reserve_bytes = max( + STAGING_FREE_SPACE_RESERVE_BYTES, + math.ceil(expected_size * 0.10), + ) + required_free_bytes = expected_size + reserve_bytes + if free_bytes < required_free_bytes: + raise PaperScriptError( + f"Staging preflight found only {format_bytes(free_bytes)} free in {self.server_dir}; " + f"{format_bytes(expected_size)} is needed for {target_path.name} and " + f"{format_bytes(reserve_bytes)} must remain free. No download was started." + ) - backups = [path for path in self.backups_dir.iterdir() if path.is_file()] - backups.sort(key=lambda item: item.stat().st_mtime, reverse=True) - for old_path in backups[self.keep_backups:]: - old_path.unlink() - self.logger.log(f"Removed old backup {old_path}") + fsync_directory(self.server_dir, strict=True) + self.logger.log( + f"Staging preflight: write access and durable directory sync confirmed; " + f"{format_bytes(required_free_bytes)} required including reserve, " + f"{format_bytes(free_bytes)} available." + ) + yield descriptor, temp_path + finally: + active_error = sys.exc_info()[1] + cleanup_errors: list[str] = [] + if descriptor >= 0: + try: + os.close(descriptor) + except OSError as error: + cleanup_errors.append(f"could not close the temporary file: {error}") + if temp_path is not None and os.path.lexists(temp_path): + try: + cleanup_stat = temp_path.lstat() + if staging_identity is None or ( + cleanup_stat.st_dev, + cleanup_stat.st_ino, + ) != staging_identity: + cleanup_errors.append( + f"refused to remove changed temporary path {temp_path}" + ) + else: + temp_path.unlink() + except OSError as error: + cleanup_errors.append(f"could not remove {temp_path}: {error}") + if cleanup_errors: + message = "Staging temporary-file cleanup was incomplete: " + "; ".join(cleanup_errors) + if active_error is None: + raise PaperScriptError(message) + self.logger.log(message) + + def assert_staging_path_identity(self, path: Path, descriptor: int) -> os.stat_result: + """Require the private path to still name the inode opened during preflight.""" + try: + path_stat = path.lstat() + descriptor_stat = os.fstat(descriptor) + except OSError as error: + raise PaperScriptError(f"Staging temporary file changed or disappeared: {error}") from error + if ( + not stat.S_ISREG(path_stat.st_mode) + or (path_stat.st_dev, path_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + ): + raise PaperScriptError( + "Staging temporary path no longer identifies the private regular file created during preflight." + ) + return descriptor_stat - def format_download_filename(self, build: BuildInfo) -> str: + def verify_existing_staged_target( + self, + target_path: Path, + build: BuildInfo, + ) -> tuple[ArtifactVerification, os.stat_result]: + """Verify an existing target without following a symlink or trusting its name.""" + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(target_path, flags) + except OSError as error: + raise PaperScriptError( + f"Could not safely open existing staging target {target_path}: {error}" + ) from error try: - return self.download_filename_pattern.format(version=build.version, build=build.build_id) - except (KeyError, IndexError, ValueError): - return f"Paper-{build.version}-{build.build_id}.jar" + descriptor_stat = os.fstat(descriptor) + path_stat = target_path.lstat() + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or (path_stat.st_dev, path_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + ): + raise PaperScriptError( + f"Existing staging target {target_path} changed identity or is not a regular file." + ) + verification = verify_paper_artifact_descriptor(descriptor, build) + return verification, descriptor_stat + finally: + os.close(descriptor) def print_dry_run_summary(self, target_path: Path, current: JarInfo | None, build: BuildInfo) -> None: - self.logger.log("Dry run: no files were changed.") + self.logger.log("Dry run: no server JAR or managed archive files were changed.") self.logger.log(f"Dry run: would download {build.download_url}") - if build.sha256: - self.logger.log(f"Dry run: expected SHA-256 from API is {build.sha256}") - self.logger.log(f"Dry run: would stage the jar in {self.downloads_dir}") - if current: - self.logger.log(f"Dry run: would back up {current.path.name} into {self.backups_dir}") + self.logger.log(f"Dry run: expected size from API is {format_bytes(build.size)}") + self.logger.log(f"Dry run: expected SHA-256 from API is {build.sha256}") + self.logger.log( + "Dry run: would preflight server-root write access, durable directory sync, and free disk reserve." + ) + self.logger.log(f"Dry run: would download to a unique temporary file in {self.server_dir}") + self.logger.log( + "Dry run: would verify the on-disk size, SHA-256, executable JAR manifest/class, " + "and every ZIP entry CRC before publication." + ) + self.logger.log( + f"Dry run: would atomically publish {target_path.name} without overwriting an existing path." + ) if target_path.exists(): - self.logger.log(f"Dry run: would also back up existing target jar {target_path.name}") - self.logger.log(f"Dry run: would install {target_path}") - if self.cleanup_backups_after_install: - self.logger.log(f"Dry run: would keep the newest {self.keep_backups} backups after install.") + self.logger.log( + f"Dry run: {target_path.name} already exists and would only be accepted if its SHA-256 matches the API." + ) + if current: + self.logger.log(f"Dry run: existing jar {current.path.name} would remain unchanged.") + if self.reconcile_server_jars_after_stage: + self.logger.log( + f"Dry run: after staging, PaperScript would keep up to {self.keep_server_jars} steady-state " + "launcher/newest root jar roles plus any in-flight launcher rollback jar, and cap the " + f"per-version jar archive at {self.keep_archived_jars}." + ) + self.logger.log("Dry run: PaperScript would not stop, start, restart, or signal the server process.") + + def stage_build( + self, + build: BuildInfo, + force_version_prompt: bool = False, + prompt_for_forced_recheck: bool = False, + selection_policy: str = STAGE_SELECTION_LATEST_CHANNEL, + required_channel: str | None = None, + ) -> None: + if self.args.dry_run: + build = self.revalidate_build_for_staging( + build, + selection_policy=selection_policy, + required_channel=required_channel, + ) + self._stage_build( + build, + force_version_prompt=force_version_prompt, + prompt_for_forced_recheck=prompt_for_forced_recheck, + ) + return + with self.server_mutation_lock(): + build = self.revalidate_build_for_staging( + build, + selection_policy=selection_policy, + required_channel=required_channel, + ) + self._stage_build( + build, + force_version_prompt=force_version_prompt, + prompt_for_forced_recheck=prompt_for_forced_recheck, + ) + + def revalidate_build_for_staging( + self, + selected: BuildInfo, + *, + selection_policy: str = STAGE_SELECTION_LATEST_CHANNEL, + required_channel: str | None = None, + ) -> BuildInfo: + """Recompute selection policy and artifact metadata from fresh Paper API data.""" + self.stage_target_path(selected) + channel = str(required_channel or selected.channel).upper() + if selection_policy == STAGE_SELECTION_EXACT: + fresh = self.api.get_build_by_id( + selected.version, + selected.build_id, + use_cache=False, + ) + elif selection_policy == STAGE_SELECTION_LATEST_CHANNEL: + fresh = self.api.get_latest_build( + selected.version, + channel=channel, + use_cache=False, + ) + elif selection_policy == STAGE_SELECTION_LATEST_OVERALL: + _, fresh = self.latest_version_for_channel( + channel, + use_cache=False, + ) + elif selection_policy == STAGE_SELECTION_LATEST_PREVIEW: + stable_version, _ = self.latest_stable_version(use_cache=False) + preview = self.latest_preview_version( + stable_version, + use_cache=False, + ) + fresh = preview[1] if preview is not None else None + else: + raise PaperScriptError( + f"Unknown staging selection policy {selection_policy!r}; staging was refused." + ) + if fresh is None: + raise PaperScriptError( + f"Paper staging selection {selected.version} build #{selected.build_id} " + f"({selection_policy}) could not be authenticated against a fresh Paper API response; " + "staging was refused." + ) + if ( + selection_policy + in {STAGE_SELECTION_LATEST_CHANNEL, STAGE_SELECTION_LATEST_OVERALL} + and fresh.channel.casefold() != channel.casefold() + ): + raise PaperScriptError( + f"Fresh Paper API selection returned channel {fresh.channel} while {channel} was required; " + "staging was refused." + ) + self.stage_target_path(fresh) + require_artifact_metadata(fresh) + if ( + fresh.version.casefold(), + fresh.build_id, + fresh.channel.casefold(), + ) != ( + selected.version.casefold(), + selected.build_id, + selected.channel.casefold(), + ): + self.logger.log( + f"Fresh Paper API selection superseded cached metadata: " + f"{selected.version} #{selected.build_id} {selected.channel} -> " + f"{fresh.version} #{fresh.build_id} {fresh.channel}." + ) + return fresh - def install_build( + def _stage_build( self, build: BuildInfo, force_version_prompt: bool = False, - prompt_for_force_reinstall: bool = False, + prompt_for_forced_recheck: bool = False, ) -> None: + target_path = self.stage_target_path(build) + expected_size = require_artifact_metadata(build) current = self.find_current_jar() - target_name = self.format_download_filename(build) - manual_force_reinstall = False + same_family = self.managed_server_jars(build.version) + known_same_family_builds = [jar.build for jar in same_family] + if current is not None and current.version.casefold() == build.version.casefold(): + known_same_family_builds.append(current.build) + newest_same_family_build = max(known_same_family_builds, default=-1) + if build.build_id < newest_same_family_build: + raise PaperScriptError( + f"Refusing to stage Paper {build.version} build #{build.build_id} while newer same-family " + f"build #{newest_same_family_build} exists. 1MB-minecraft.sh always selects the greatest " + "numeric build, so the requested downgrade could not become the next launch." + ) + manual_forced_recheck = False if current and current.version == build.version and current.build >= build.build_id and not self.args.force: if ( current.build == build.build_id - and prompt_for_force_reinstall + and prompt_for_forced_recheck and not self.args.dry_run and sys.stdin.isatty() ): if prompt_yes_no( - f"Current jar {current.path.name} is already build #{current.build}. Download it anyway?", + f"Build {current.path.name} is already staged. Re-check it against the Paper API?", default=False, logger=self.logger, ): self.logger.log("Proceeding with a forced re-download of the same build.") - manual_force_reinstall = True + manual_forced_recheck = True else: self.logger.log("Cancelled re-download of the same build.") return - if not manual_force_reinstall: + if not manual_forced_recheck: self.logger.log( - f"Current jar {current.path.name} is already build #{current.build} for version {current.version}. " - "Nothing newer needs to be downloaded." + f"Newest managed jar {current.path.name} is already build #{current.build} for version {current.version}. " + "Nothing newer needs to be staged." ) return if current and current.version == build.version and current.build < build.build_id and not self.allow_same_version_build_upgrade: - self.logger.log("Same-version build upgrades are disabled in config, so the newer build will not be installed.") + self.logger.log("Same-version build upgrades are disabled in config, so the newer build will not be staged.") return - force_requested = self.args.force or manual_force_reinstall - if current and force_requested and self.confirm_before_force_download and not self.args.yes and not self.args.dry_run and not manual_force_reinstall: + force_requested = self.args.force or manual_forced_recheck + if current and force_requested and self.confirm_before_force_download and not self.args.yes and not self.args.dry_run and not manual_forced_recheck: if not prompt_yes_no( - "Force download is enabled. Continue with the requested install?", + "Force download is enabled. Continue with the requested stage?", default=False, logger=self.logger, ): @@ -1588,117 +3186,342 @@ def install_build( elif current and force_requested and self.confirm_before_force_download and self.args.dry_run and not self.args.yes: self.logger.log("Dry run: PaperScript would ask for confirmation before a forced download.") - if current and compare_versions(current.version, build.version) > 0 and self.confirm_before_downgrade and not self.args.yes and not self.args.dry_run: + try: + launcher_intent_version = self.launcher_jar_selection().version + except PaperScriptError: + launcher_intent_version = current.version if current is not None else None + + if ( + launcher_intent_version + and compare_versions(launcher_intent_version, build.version) > 0 + and self.confirm_before_downgrade + and not self.args.yes + and not self.args.dry_run + ): if not prompt_yes_no( - f"This appears to be a downgrade from version {current.version} to {build.version}. Continue?", + f"Stage older Minecraft family {build.version} beside the launcher family " + f"{launcher_intent_version}? This does not change launcher selection. Continue?", default=False, logger=self.logger, ): - self.logger.log("Cancelled downgrade.") + self.logger.log("Cancelled older-family staging.") return elif ( - current - and compare_versions(current.version, build.version) > 0 + launcher_intent_version + and compare_versions(launcher_intent_version, build.version) > 0 and self.confirm_before_downgrade and self.args.dry_run and not self.args.yes ): self.logger.log( - f"Dry run: PaperScript would ask before downgrading from {current.version} to {build.version}." + f"Dry run: PaperScript would ask before staging older family {build.version} beside " + f"launcher family {launcher_intent_version}; launcher selection would not change." ) - if current and compare_versions(current.version, build.version) < 0 and force_version_prompt and not self.args.yes: + if ( + launcher_intent_version + and compare_versions(launcher_intent_version, build.version) < 0 + and force_version_prompt + and not self.args.yes + ): if self.args.dry_run: self.logger.log( - f"Dry run: PaperScript would ask before upgrading from {current.version} to {build.version}." + f"Dry run: PaperScript would ask before staging newer family {build.version} beside " + f"launcher family {launcher_intent_version}." ) elif not prompt_yes_no( - f"This will upgrade from version {current.version} to {build.version}. Continue?", + f"Stage newer Minecraft family {build.version} beside launcher family " + f"{launcher_intent_version}? This does not change launcher selection. Continue?", default=False, logger=self.logger, ): - self.logger.log("Cancelled version upgrade.") + self.logger.log("Cancelled newer-family staging.") return - self.ensure_safe_to_upgrade() - - target_path = self.server_dir / target_name - temp_path = self.downloads_dir / f"{target_name}.part" - final_temp = self.downloads_dir / target_name - if self.args.dry_run: self.print_dry_run_summary(target_path, current, build) return - if temp_path.exists(): - temp_path.unlink() - if final_temp.exists(): - final_temp.unlink() + if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): + raise PaperScriptError(f"Refusing to replace a symlink or non-file staging target: {target_path}") + existing_identity: tuple[int, int, int, int, str] | None = None + if target_path.exists(): + try: + existing_verification, existing_stat = self.verify_existing_staged_target( + target_path, + build, + ) + except PaperScriptError as error: + raise PaperScriptError( + f"Target {target_path.name} already exists but failed exact Paper artifact " + f"validation ({error}). PaperScript will not overwrite a possibly active or " + "locally modified jar." + ) from error + if not force_requested: + self.logger.log( + f"Staged target {target_path.name} already matches the Paper API size and SHA-256 " + "and is an intact executable JAR; left it unchanged." + ) + self.record_state(build, target_path, existing_verification.sha256) + self.reconcile_after_stage(build.version, target_path) + return + existing_identity = ( + existing_stat.st_dev, + existing_stat.st_ino, + existing_stat.st_size, + existing_stat.st_mtime_ns, + existing_verification.sha256, + ) + self.logger.log( + f"Force re-download will verify fresh bytes for {target_path.name} without replacing the existing file." + ) + + stage_started_at = time.monotonic() + with self.private_staging_file(target_path, expected_size) as (descriptor, temp_path): + self.logger.log( + f"Downloading Paper {build.version} build #{build.build_id} " + f"({build.channel}, {format_bytes(expected_size)})..." + ) + try: + download_verification = self.api.download_file(build, temp_path, descriptor) + except PaperScriptError: + raise + except OSError as error: + raise PaperScriptError( + f"Download I/O failed for {build.filename}: {error}" + ) from error + + self.assert_staging_path_identity(temp_path, descriptor) + if download_verification.bytes_written != expected_size: + raise PaperScriptError( + f"Downloaded size mismatch for {build.filename}: expected {expected_size}, " + f"transport reported {download_verification.bytes_written} bytes." + ) + artifact_verification = verify_paper_artifact_descriptor(descriptor, build) + if str(download_verification.sha256).lower() != artifact_verification.sha256.lower(): + raise PaperScriptError( + f"Download stream SHA-256 for {build.filename} disagrees with the verified " + "on-disk staging inode." + ) + + try: + os.fchmod(descriptor, 0o644) + os.fsync(descriptor) + except OSError as error: + raise PaperScriptError( + f"Could not durably sync the verified JAR before publication: {error}" + ) from error + self.assert_staging_path_identity(temp_path, descriptor) + self.logger.log( + f"JAR validation: {artifact_verification.entry_count} files passed ZIP CRC checks; " + f"executable Main-Class is {artifact_verification.main_class}." + ) + + if existing_identity is not None: + try: + after_verification, after = self.verify_existing_staged_target( + target_path, + build, + ) + except PaperScriptError as error: + raise PaperScriptError( + f"Existing target {target_path.name} changed during force re-download; " + "left it unchanged." + ) from error + after_identity = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after_verification.sha256, + ) + if after_identity != existing_identity: + raise PaperScriptError( + f"Existing target {target_path.name} changed during force re-download; no file was replaced." + ) + else: + try: + os.link(temp_path, target_path, follow_symlinks=False) + except FileExistsError as error: + raise PaperScriptError( + f"Target {target_path.name} appeared while the download was in progress; no file was overwritten." + ) from error + except OSError as error: + raise PaperScriptError( + f"Could not atomically publish {target_path.name} without overwriting an existing " + f"path: {error}" + ) from error + try: + published_stat = target_path.lstat() + staged_stat = os.fstat(descriptor) + except OSError as error: + raise PaperScriptError( + f"Could not confirm the published identity for {target_path.name}: {error}" + ) from error + if ( + not stat.S_ISREG(published_stat.st_mode) + or (published_stat.st_dev, published_stat.st_ino) + != (staged_stat.st_dev, staged_stat.st_ino) + ): + raise PaperScriptError( + f"Published target {target_path.name} did not retain the verified staging-file identity." + ) + try: + temp_path.unlink() + fsync_directory(self.server_dir, strict=True) + except (OSError, PaperScriptError) as error: + raise PaperScriptError( + f"Verified target {target_path.name} was published, but final temporary-link " + f"cleanup or directory synchronization failed: {error}" + ) from error + + if existing_identity is None: + self.logger.log( + f"Staged: {target_path.name} ({build.channel}, size, SHA-256, and executable JAR verified)" + ) + else: + self.logger.log( + f"Re-downloaded and verified: {target_path.name} ({build.channel}); existing staged file unchanged" + ) + self.logger.log(f"Expected SHA-256: {build.sha256}") + self.logger.log(f"Downloaded SHA-256: {artifact_verification.sha256}") self.logger.log( - f"Downloading Paper {build.version} build #{build.build_id} " - f"({build.channel}, {format_bytes(build.size)})..." + f"Download timing: {format_duration(download_verification.elapsed_seconds)} at " + f"{format_rate(download_verification.bytes_written, download_verification.elapsed_seconds)}" ) - install_started_at = time.monotonic() + self.record_state(build, target_path, artifact_verification.sha256) + self.reconcile_after_stage(build.version, target_path) + self.logger.log(f"Total staging timing: {format_duration(time.monotonic() - stage_started_at)}") + self.logger.log("PaperScript did not stop, start, restart, or signal the server process.") + self.logger.log("Full server, world, plugin, and BlueMap backups are separate and were not run.") + + def reconcile_after_stage(self, version: str, staged_path: Path) -> None: + if not self.reconcile_server_jars_after_stage: + self.logger.log("Automatic server-root jar retention is disabled in config.") + return try: - verification = self.api.download_file(build, temp_path) - except Exception: - if temp_path.exists(): - temp_path.unlink() - raise - temp_path.rename(final_temp) - self.logger.log(f"Downloaded to {final_temp}") - if build.sha256: - self.logger.log(f"Expected SHA-256: {build.sha256}") - self.logger.log(f"Downloaded SHA-256: {verification.sha256}") - self.logger.log( - f"Checksum verification: {'match' if not build.sha256 or verification.sha256.lower() == build.sha256.lower() else 'mismatch'}" - ) + plan = self.plan_server_jar_retention( + version, + keep=self.keep_server_jars, + staged_path=staged_path, + ) + archived = self.apply_server_jar_retention(plan) + except PaperScriptError as error: + self.logger.log(f"Automatic server-root jar retention deferred: {error}") + return + rollback_note = "; includes one in-flight launcher rollback jar" if plan.launch_rollback else "" self.logger.log( - f"Download timing: {format_duration(verification.elapsed_seconds)} at " - f"{format_rate(verification.bytes_written, verification.elapsed_seconds)}" + f"Server-root managed jars: {len(plan.kept)} protected " + f"(steady-state limit {self.keep_server_jars}{rollback_note}); " + f"archived {archived} older matching jar(s)." ) - self.backup_existing_jar(current, target_name) - shutil.move(str(final_temp), str(target_path)) - self.logger.log(f"Installed {target_path}") - self.record_state(build, target_path, verification.sha256) - self.prune_old_backups() - self.logger.log(f"Total install timing: {format_duration(time.monotonic() - install_started_at)}") - def run_update(self) -> None: self.describe_server_context() self.log_api_activity("Contacting Paper API for the latest stable release...") - target = self.choose_target_for_update() - if target is None: - self.logger.log("Update finished with no download or install changes.") + selection = self.choose_target_for_update(use_cache=False) + if selection is None: + self.logger.log("Update finished with no staging changes.") return - self.install_build(target, force_version_prompt=False) + self.stage_build( + selection.build, + force_version_prompt=False, + selection_policy=selection.staging_policy, + required_channel=self.check_latest_channel_only, + ) def run_download(self, version: str, build_id: int | None, channel: str) -> None: if build_id is not None: - builds = self.api.get_builds(version) - selected = next((build for build in builds if build.build_id == build_id), None) + selected = self.api.get_build_by_id( + version, + build_id, + use_cache=False, + ) if not selected: raise PaperScriptError( f"Build #{build_id} was not found for version {version}. " f"Try './paperscript.sh inspect {version}' to see the available builds first." ) - self.install_build(selected, force_version_prompt=True, prompt_for_force_reinstall=True) + self.stage_build( + selected, + force_version_prompt=True, + prompt_for_forced_recheck=True, + selection_policy=STAGE_SELECTION_EXACT, + ) return - selected = self.api.get_latest_build(version, channel=channel) + selected = self.api.get_latest_build( + version, + channel=channel, + use_cache=False, + ) if not selected: raise PaperScriptError( f"No {channel.upper()} build was found for version {version}. " f"Try './paperscript.sh inspect {version}' to see which channels exist." ) - self.install_build(selected, force_version_prompt=True, prompt_for_force_reinstall=True) + self.stage_build( + selected, + force_version_prompt=True, + prompt_for_forced_recheck=True, + selection_policy=STAGE_SELECTION_LATEST_CHANNEL, + required_channel=channel, + ) def run_status(self) -> None: compact = self.effective_status_view() == "compact" properties = parse_properties(self.server_dir / "server.properties") current = self.find_current_jar() + try: + launcher_selection = self.launcher_jar_selection() + launcher_marker_error = None + except PaperScriptError as error: + launcher_selection = None + launcher_marker_error = str(error) + + if launcher_selection is not None and launcher_selection.build is not None: + last_launched = JarInfo( + launcher_selection.path, + launcher_selection.version, + launcher_selection.build, + ) + else: + last_launched = None + + launcher_family_jars = ( + self.managed_server_jars(launcher_selection.version) + if launcher_selection is not None + else [] + ) + if launcher_selection is None: + next_launcher_status = "unknown; launcher identity unavailable" + elif launcher_family_jars: + next_launcher = launcher_family_jars[0] + if next_launcher.path == launcher_selection.path: + next_launcher_status = f"{next_launcher.path.name} (same as last selection)" + elif launcher_selection.build is None: + next_launcher_status = ( + f"{next_launcher.path.name} (numeric build supersedes legacy fallback)" + ) + else: + next_launcher_status = f"{next_launcher.path.name} (newer than last selection)" + else: + next_launcher_status = f"{launcher_selection.path.name} (legacy fallback)" + status_current = current + if launcher_selection is not None: + marker_state = self.jar_info_from_state(launcher_selection.path) + status_current = ( + launcher_family_jars[0] + if launcher_family_jars + else marker_state + if marker_state is not None + else JarInfo( + launcher_selection.path, + launcher_selection.version, + launcher_selection.build if launcher_selection.build is not None else -1, + ) + ) running = self.detect_running_server_processes() self.log_api_activity("Contacting Paper API for current release data...") latest_version, latest_build = self.latest_stable_version() @@ -1706,7 +3529,7 @@ def run_status(self) -> None: tmux_available = self.tmux_session_available() backup_count = self.backup_file_count() metadata_cache_count = self.metadata_cache_file_count() - update_relevant = current is None + update_relevant = status_current is None self.logger.kv("PaperScript version", APP_RELEASE) self.logger.kv("Server directory", str(self.server_dir)) @@ -1715,7 +3538,7 @@ def run_status(self) -> None: self.logger.kv("Server label", str(self.server_name or "none")) self.logger.kv("tmux session", self.tmux_session) self.logger.kv("tmux session available", format_bool(tmux_available)) - self.logger.kv("Graceful stop command", self.graceful_stop_command) + self.logger.kv("Lifecycle control", "manual/external; PaperScript never stops or starts the server") self.logger.kv("Server properties found", format_bool((self.server_dir / "server.properties").exists())) self.logger.kv("Configured server port", properties.get("server-port", "25565")) self.logger.kv("Running server detected", format_bool(bool(running))) @@ -1723,71 +3546,105 @@ def run_status(self) -> None: for pid, command in running: self.logger.kv(f" PID {pid}", command) if current: - state_jar = self.state.get("current_jar") - state_channel = self.state.get("current_channel") if state_jar == current.path.name else None + state_jar = self.state.get("staged_jar") or self.state.get("current_jar") + state_channel = ( + self.state.get("staged_channel") or self.state.get("current_channel") + ) if state_jar == current.path.name else None current_details = f"{current.path.name} (version {current.version}, build #{current.build}" if state_channel: current_details += f", channel {str(state_channel).upper()}" current_details += ")" self.logger.kv( - "Current jar", + "Newest managed jar", current_details, ) current_sha = sha256_file(current.path) if not compact: - self.logger.kv("Current jar path", str(current.path)) - self.logger.kv("Current jar SHA-256", current_sha) + self.logger.kv("Newest managed jar path", str(current.path)) + self.logger.kv("Newest managed SHA-256", current_sha) expected_sha = self.state.get("expected_sha256") if expected_sha and state_jar == current.path.name and not compact: self.logger.kv("Expected SHA-256", str(expected_sha)) self.logger.kv( - "Current SHA matches expected", + "Newest managed SHA matches expected", format_bool(current_sha.lower() == str(expected_sha).lower()), ) else: - self.logger.kv("Current jar", "none") + self.logger.kv("Newest managed jar", "none") + if launcher_selection is None: + launcher_status = "unknown; run the updated 1MB-minecraft.sh once" + if launcher_marker_error and not compact: + launcher_status += f" ({launcher_marker_error})" + elif launcher_selection.build is None: + launcher_status = ( + f"{launcher_selection.path.name} (legacy name; numeric-build retention is deferred)" + ) + else: + launcher_status = launcher_selection.path.name + self.logger.kv("Last launcher-selected jar", launcher_status) + self.logger.kv( + "Predicted next selection (last marker family)", + next_launcher_status, + ) self.logger.kv( f"Latest {self.check_latest_channel_only.lower()} release", f"{latest_version} build #{latest_build.build_id}", ) - if current is None: + family_latest_build = latest_build + if status_current is not None and status_current.version.casefold() != latest_version.casefold(): + family_latest_build = self.api.get_latest_build( + status_current.version, + channel=self.check_latest_channel_only, + ) + + if status_current is None: self.logger.kv( "Update status", - "no installed jar detected, so PaperScript would offer the latest release.", + "no managed jar detected, so PaperScript would offer the latest release.", ) update_relevant = True + elif family_latest_build is None: + self.logger.kv( + "Update status", + f"no {self.check_latest_channel_only.lower()} build was found for launcher family {status_current.version}.", + ) else: - version_cmp = compare_versions(current.version, latest_version) - if version_cmp == 0: - if latest_build.build_id > current.build: - self.logger.kv( - "Update status", - f"newer build available for the same version ({current.build} -> {latest_build.build_id}).", - ) - update_relevant = True - elif latest_build.build_id == current.build: - self.logger.kv("Update status", "already on the latest stable build.") - else: - self.logger.kv( - "Update status", - "installed build is newer than the latest stable build this script found.", - ) - elif version_cmp < 0: + if status_current.build < 0: + self.logger.kv( + "Update status", + f"launcher family {status_current.version} uses a legacy jar with an unknown build; " + f"update can stage stable build #{family_latest_build.build_id}.", + ) + update_relevant = True + elif family_latest_build.build_id > status_current.build: self.logger.kv( "Update status", - f"newer version available ({current.version} -> {latest_version}).", + f"newer stable build available for launcher family {status_current.version} " + f"({status_current.build} -> {family_latest_build.build_id}).", ) update_relevant = True + elif family_latest_build.build_id == status_current.build: + self.logger.kv("Update status", "latest stable build for the launcher family is already staged.") else: self.logger.kv( "Update status", - "installed version is newer than the latest stable version this script found.", + "newest managed build for the launcher family is newer than the latest stable build this script found.", ) + if status_current is not None and compare_versions(latest_version, status_current.version) > 0: + self.logger.kv( + "Newer Minecraft family", + f"{latest_version} build #{latest_build.build_id}; update remains on {status_current.version}.", + ) + self.log_command_hint( + f"After reviewing launcher/plugin compatibility, stage it explicitly with " + f"'./paperscript.sh download --version {latest_version} --channel STABLE'." + ) + update_relevant = True self.log_command_hint( - "Use './paperscript.sh stable' to inspect the latest stable release, './paperscript.sh update' to install it, " - "or './paperscript.sh --force update' to re-download it even if it is already installed." + "Use './paperscript.sh stable' to inspect the latest stable release, './paperscript.sh update' to stage " + "the newest stable build for the launcher family, or './paperscript.sh --force update' to re-check it." ) self.log_release_page(update_relevant) @@ -1817,13 +3674,20 @@ def run_status(self) -> None: f"{latest_preview_version} build #{latest_preview_build.build_id} ({latest_preview_build.channel})", ) self.log_command_hint( - "Use './paperscript.sh experimental' to inspect it, or './paperscript.sh experimental --download' to install it." + "Use './paperscript.sh experimental' to inspect it, or './paperscript.sh experimental --download' to stage it." ) self.logger.kv( - "Backup retention", - f"keep {self.keep_backups} backups, cleanup after install {format_bool(self.cleanup_backups_after_install)}", + "Server-root jar retention", + f"steady-state limit {self.keep_server_jars} for last-launched/newest roles; " + "one in-flight launcher rollback may be added; reconcile after stage " + f"{format_bool(self.reconcile_server_jars_after_stage)}", ) - self.logger.kv("Backups found", f"{backup_count} file(s)") + if last_launched: + self.logger.kv( + f"Jar archive ({last_launched.version})", + f"{self.managed_jar_archive_count(last_launched.version)}/{self.keep_archived_jars} file(s)", + ) + self.logger.kv("Legacy backups found", f"{backup_count} file(s)") self.logger.kv( "Metadata cache", f"{'enabled' if self.metadata_cache_enabled else 'disabled'}, " @@ -1832,13 +3696,13 @@ def run_status(self) -> None: if backup_count > 0: if self.keep_backups >= 0 and backup_count > self.keep_backups: self.log_command_hint( - f"Run './paperscript.sh cleanup --backups --keep {self.keep_backups}' to trim older backups, " - "or './paperscript.sh cleanup --backups' to delete them all.", + f"Run './paperscript.sh cleanup --backups --keep {self.keep_backups}' to trim older legacy backups, " + "or './paperscript.sh cleanup --backups' to delete those legacy items.", important=True, ) elif not compact: self.log_command_hint( - "Run './paperscript.sh cleanup --backups' to delete all backup jars if you no longer need rollback copies." + "Run './paperscript.sh cleanup --backups' to delete legacy backup items; the managed JAR archive is preserved." ) if metadata_cache_count > 0 and not compact: self.log_command_hint( @@ -1848,7 +3712,7 @@ def run_status(self) -> None: def run_stable(self, download: bool = False) -> None: self.log_api_activity("Contacting Paper API for the latest stable release...") - version, build = self.latest_stable_version() + version, build = self.latest_stable_version(use_cache=not download) self.logger.log(f"Latest stable release overall: {version} build #{build.build_id} ({format_bytes(build.size)})") self.logger.log(f"Download URL: {build.download_url}") if build.sha256: @@ -1858,12 +3722,21 @@ def run_stable(self, download: bool = False) -> None: ) self.log_release_page(relevant=True) if download: - self.install_build(build, force_version_prompt=False, prompt_for_force_reinstall=True) + self.stage_build( + build, + force_version_prompt=True, + prompt_for_forced_recheck=True, + selection_policy=STAGE_SELECTION_LATEST_OVERALL, + required_channel=self.check_latest_channel_only, + ) def run_experimental(self, download: bool = False) -> None: self.log_api_activity("Contacting Paper API for the latest preview release newer than stable...") - stable_version, stable_build = self.latest_stable_version() - preview = self.latest_preview_version(stable_version) + stable_version, stable_build = self.latest_stable_version(use_cache=not download) + preview = self.latest_preview_version( + stable_version, + use_cache=not download, + ) if preview is None: self.logger.log( f"No preview release newer than the current stable release was found. Stable is {stable_version} build #{stable_build.build_id}." @@ -1886,69 +3759,220 @@ def run_experimental(self, download: bool = False) -> None: ) self.log_release_page(relevant=True) if download: - self.install_build(build, force_version_prompt=True, prompt_for_force_reinstall=True) + self.stage_build( + build, + force_version_prompt=True, + prompt_for_forced_recheck=True, + selection_policy=STAGE_SELECTION_LATEST_PREVIEW, + ) + + def open_verify_target(self, path: Path) -> tuple[int, os.stat_result]: + """Open the verify target without following a symlink and bind it to its path.""" + flags = os.O_RDONLY + flags |= getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise PaperScriptError( + f"Could not safely open verify target {path}: {error}" + ) from error + try: + descriptor_stat = os.fstat(descriptor) + path_stat = path.lstat() + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or (path_stat.st_dev, path_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + ): + raise PaperScriptError( + f"Verify target {path} changed identity or is not a regular non-symlink file." + ) + return descriptor, descriptor_stat + except BaseException: + os.close(descriptor) + raise + + def assert_verify_target_unchanged( + self, + path: Path, + descriptor: int, + expected: os.stat_result, + ) -> None: + """Require the path and opened verify inode to remain stable through the check.""" + try: + descriptor_stat = os.fstat(descriptor) + path_stat = path.lstat() + except OSError as error: + raise PaperScriptError( + f"Verify target {path} changed identity or disappeared during verification: {error}" + ) from error + expected_identity = ( + expected.st_dev, + expected.st_ino, + expected.st_size, + expected.st_mtime_ns, + expected.st_ctime_ns, + ) + descriptor_identity = ( + descriptor_stat.st_dev, + descriptor_stat.st_ino, + descriptor_stat.st_size, + descriptor_stat.st_mtime_ns, + descriptor_stat.st_ctime_ns, + ) + if ( + not stat.S_ISREG(descriptor_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or (path_stat.st_dev, path_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + or descriptor_identity != expected_identity + ): + raise PaperScriptError( + f"Verify target {path} changed identity or contents during verification." + ) def run_verify(self) -> None: + with self.server_mutation_lock(): + self._run_verify_locked() + + def _run_verify_locked(self) -> None: current = self.find_current_jar() if not current: - raise PaperScriptError("No current Paper jar was detected to verify.") + raise PaperScriptError("No managed Paper jar was detected to verify.") - current_sha = sha256_file(current.path) - self.logger.log( - f"Verify target: {current.path.name} (version {current.version}, build #{current.build})" - ) - self.logger.log(f"Current SHA-256: {current_sha}") - - state_jar = self.state.get("current_jar") - state_channel = self.state.get("current_channel") - state_expected = self.state.get("expected_sha256") - state_current = self.state.get("current_sha256") - if state_jar == current.path.name: - if state_channel: - self.logger.log(f"Recorded install channel: {str(state_channel).upper()}") - if not state_current and not state_expected: - self.logger.log("Recorded install state exists for this jar, but it does not contain stored SHA-256 values yet.") - if state_current: - self.logger.log(f"Recorded SHA-256 from install time: {state_current}") - self.logger.log( - f"Current SHA-256 matches recorded install SHA: " - f"{format_bool(current_sha.lower() == str(state_current).lower())}" + descriptor, target_identity = self.open_verify_target(current.path) + try: + current_sha = sha256_descriptor(descriptor) + self.assert_verify_target_unchanged( + current.path, + descriptor, + target_identity, + ) + self.logger.log( + f"Verify target: {current.path.name} (version {current.version}, build #{current.build})" + ) + self.logger.log(f"Newest managed SHA-256: {current_sha}") + + state_jar = self.state.get("staged_jar") or self.state.get("current_jar") + state_channel = self.state.get("staged_channel") or self.state.get("current_channel") + state_expected = self.state.get("expected_sha256") + state_current = self.state.get("staged_sha256") + if state_current is None or state_current == "": + state_current = self.state.get("current_sha256") + if state_jar == current.path.name: + if state_channel: + self.logger.log(f"Recorded staging channel: {str(state_channel).upper()}") + if not state_current and not state_expected: + self.logger.log( + "Recorded staging state exists for this jar, but it does not contain stored SHA-256 values yet." + ) + state_checks = ( + ( + "staging", + "Recorded SHA-256 from staging time", + "Newest managed SHA-256 matches recorded staging SHA", + state_current, + ), + ( + "expected", + "Recorded expected SHA-256", + "Newest managed SHA-256 matches recorded expected SHA", + state_expected, + ), ) - if state_expected: - self.logger.log(f"Recorded expected SHA-256: {state_expected}") + for state_label, value_label, match_label, recorded_value in state_checks: + if recorded_value is None or recorded_value == "": + continue + recorded_sha = normalized_sha256(recorded_value) + if recorded_sha is None: + raise PaperScriptError( + f"Verification failed: recorded {state_label} SHA-256 is invalid for {current.path.name}." + ) + self.logger.log(f"{value_label}: {recorded_sha}") + state_matches = secrets.compare_digest(current_sha, recorded_sha) + self.logger.log(f"{match_label}: {format_bool(state_matches)}") + if not state_matches: + raise PaperScriptError( + f"Verification failed: {current.path.name} does not match the recorded " + f"{state_label} SHA-256." + ) + else: self.logger.log( - f"Current SHA-256 matches recorded expected SHA: " - f"{format_bool(current_sha.lower() == str(state_expected).lower())}" + "Recorded staging state does not match the currently detected jar, so local state comparison is unavailable." ) - else: - self.logger.log("Recorded install state does not match the currently detected jar, so local state comparison is unavailable.") - api_build: BuildInfo | None = None - try: - api_build = self.api.get_build_by_id(current.version, current.build) - except PaperScriptError as error: - self.logger.log(f"API checksum lookup unavailable: {error}") + try: + api_build = self.api.get_build_by_id( + current.version, + current.build, + use_cache=False, + ) + except PaperScriptError as error: + raise PaperScriptError( + f"Fresh API checksum lookup failed for {current.path.name}: {error}" + ) from error - if api_build is None: - self.logger.log("API checksum verification: unavailable for this jar or the API could not be reached.") - return + if api_build is None: + raise PaperScriptError( + f"Verification failed: exact build {current.version} #{current.build} " + "was not found by the fresh Paper API lookup." + ) - self.logger.log(f"API channel: {api_build.channel}") - self.logger.log(f"API download URL: {api_build.download_url}") - if api_build.sha256: - self.logger.log(f"API expected SHA-256: {api_build.sha256}") + api_sha = normalized_sha256(api_build.sha256) + if api_sha is None: + raise PaperScriptError( + f"Verification failed: the fresh Paper API did not provide a valid SHA-256 " + f"for {current.path.name}." + ) + + self.logger.log(f"API channel: {api_build.channel}") + self.logger.log(f"API download URL: {api_build.download_url}") + self.logger.log(f"API expected SHA-256: {api_sha}") + api_matches = secrets.compare_digest(current_sha, api_sha) self.logger.log( - f"Current SHA-256 matches API expected SHA: " - f"{format_bool(current_sha.lower() == api_build.sha256.lower())}" + f"Newest managed SHA-256 matches API expected SHA: {format_bool(api_matches)}" ) - else: - self.logger.log("API checksum verification: this build did not include a SHA-256 in the API response.") + if not api_matches: + raise PaperScriptError( + f"Verification failed: {current.path.name} does not match the fresh Paper API SHA-256." + ) + + self.assert_verify_target_unchanged( + current.path, + descriptor, + target_identity, + ) + final_current = self.find_current_jar() + if ( + final_current is None + or final_current.path != current.path + or final_current.version != current.version + or final_current.build != current.build + ): + final_name = final_current.path.name if final_current is not None else "none" + raise PaperScriptError( + f"Verification failed: {current.path.name} is no longer the newest managed jar " + f"(current selection: {final_name})." + ) + self.assert_verify_target_unchanged( + current.path, + descriptor, + target_identity, + ) + self.logger.log( + "Verification succeeded: newest managed jar matches the fresh Paper API SHA-256." + ) + finally: + os.close(descriptor) def cleanup_selection(self) -> dict[str, bool]: selected = { "all": bool(getattr(self.args, "cleanup_all", False)), "downloads": bool(getattr(self.args, "cleanup_downloads", False)), "backups": bool(getattr(self.args, "cleanup_backups", False)), + "server_jars": bool(getattr(self.args, "cleanup_server_jars", False)), "metadata_cache": bool(getattr(self.args, "cleanup_metadata_cache", False)), "pycache": bool(getattr(self.args, "cleanup_pycache", False)), "logs": bool(getattr(self.args, "cleanup_logs", False)), @@ -1957,8 +3981,15 @@ def cleanup_selection(self) -> dict[str, bool]: if selected["all"]: for key in ["downloads", "backups", "metadata_cache", "pycache", "logs", "json"]: selected[key] = True + if selected["backups"] and selected["server_jars"]: + raise PaperScriptError( + "--backups/--all cannot be combined with --server-jars; clean them in separate commands." + ) + if getattr(self.args, "cleanup_version", None) is not None and not selected["server_jars"]: + raise PaperScriptError("cleanup --version requires the explicit --server-jars target.") if getattr(self.args, "cleanup_keep", None) is not None: - selected["backups"] = True + if not selected["server_jars"]: + selected["backups"] = True if not any(selected.values()): selected["downloads"] = True selected["pycache"] = True @@ -1967,13 +3998,27 @@ def cleanup_selection(self) -> dict[str, bool]: def cleanup_descriptions(self, selection: dict[str, bool]) -> list[str]: descriptions: list[str] = [] if selection["downloads"]: - descriptions.append(f"Delete staged downloads and temp files in {self.downloads_dir}") + descriptions.append(f"Delete old download-workspace items in {self.downloads_dir}") if selection["backups"]: keep = getattr(self.args, "cleanup_keep", None) if keep is None: - descriptions.append(f"Delete all backup jars in {self.backups_dir}") + descriptions.append( + f"Delete legacy top-level backup items in {self.backups_dir} while preserving {self.jar_archive_dir}" + ) else: - descriptions.append(f"Trim backup jars in {self.backups_dir} so only the newest {keep} remain") + descriptions.append( + f"Trim legacy top-level backup items in {self.backups_dir} so only the newest {keep} remain" + ) + if selection["server_jars"]: + keep = getattr(self.args, "cleanup_keep", None) + keep_count = self.keep_server_jars if keep is None else int(keep) + requested_version = getattr(self.args, "cleanup_version", None) + version_label = requested_version or "the version in the launcher marker" + descriptions.append( + f"Keep the last-launched jar plus the greatest numeric next-start jar (steady-state limit " + f"{keep_count}) and any in-flight launcher rollback jar for {version_label}; archive older " + f"exact numeric builds under {self.jar_archive_dir}" + ) if selection["metadata_cache"]: descriptions.append(f"Delete cached Paper API metadata in {self.metadata_cache_dir}") if selection["pycache"]: @@ -1986,11 +4031,18 @@ def cleanup_descriptions(self, selection: dict[str, bool]) -> list[str]: ) return descriptions - def remove_directory_contents(self, path: Path) -> int: + def remove_directory_contents( + self, + path: Path, + excluded_paths: tuple[Path, ...] = (), + ) -> int: if not path.exists(): return 0 + excluded = set(excluded_paths) removed = 0 for child in path.iterdir(): + if child in excluded: + continue if child.is_dir(): shutil.rmtree(child) else: @@ -2019,7 +4071,10 @@ def trim_backups_to_keep(self, keep: int) -> int: def console_only(self, message: str) -> None: if not self.logger.quiet: - print(message) + if isinstance(message, StyledConsoleText): + print(str(message)) + else: + print(terminal_safe_text(message)) def run_init(self) -> None: actions: list[str] = [] @@ -2072,7 +4127,44 @@ def run_cleanup(self) -> None: for description in descriptions: self.logger.log(f" - {description}") + server_jar_plan: JarRetentionPlan | None = None + if selection["server_jars"]: + marker_jar = self.last_launched_jar() + requested_version = getattr(self.args, "cleanup_version", None) + version = str(requested_version or marker_jar.version) + keep = getattr(self.args, "cleanup_keep", None) + keep_count = self.keep_server_jars if keep is None else int(keep) + staged_path = self.recorded_staged_jar(version) + server_jar_plan = self.plan_server_jar_retention( + version, + keep=keep_count, + staged_path=staged_path, + ) + self.logger.log( + f"Protected last-launched jar: {server_jar_plan.last_launched.name}" + ) + if server_jar_plan.launch_rollback is not None: + self.logger.log( + f"Protected in-flight launcher rollback jar: {server_jar_plan.launch_rollback.name}" + ) + for path in server_jar_plan.kept: + if path not in { + server_jar_plan.last_launched, + server_jar_plan.launch_rollback, + }: + self.logger.log(f"Protected staged/newest jar: {path.name}") + if not server_jar_plan.to_archive: + self.logger.log("No older matching server-root jars need to be archived.") + for path in server_jar_plan.to_archive: + self.logger.log(f"Will archive older server-root jar: {path.name}") + for path in server_jar_plan.to_prune: + self.logger.log( + f"Will permanently prune archived Paper jar beyond the configured cap: {path}" + ) + if self.args.dry_run: + if server_jar_plan is not None: + self.apply_server_jar_retention(server_jar_plan, dry_run=True) self.logger.log("Dry run: no files were deleted.") return @@ -2083,19 +4175,51 @@ def run_cleanup(self) -> None: removed_downloads = 0 removed_backups = 0 + archived_server_jars = 0 removed_metadata_cache = 0 removed_pycache = 0 cleared_logs = False removed_json = 0 + if selection["server_jars"]: + marker_jar = self.last_launched_jar() + requested_version = getattr(self.args, "cleanup_version", None) + version = str(requested_version or marker_jar.version) + keep = getattr(self.args, "cleanup_keep", None) + keep_count = self.keep_server_jars if keep is None else int(keep) + with self.server_mutation_lock(): + self.state = self._load_json(self.state_path) + staged_path = self.recorded_staged_jar(version) + locked_plan = self.plan_server_jar_retention( + version, + keep=keep_count, + staged_path=staged_path, + ) + if locked_plan != server_jar_plan: + raise PaperScriptError( + "Server-root jars or launcher/staging identity changed after the cleanup plan was shown. " + "Nothing was archived; re-run cleanup to review the new plan." + ) + archived_server_jars = self.apply_server_jar_retention(locked_plan) + self.logger.log( + f"Archived {archived_server_jars} older matching server-root jar(s); " + f"kept {len(locked_plan.kept)} protected jar(s) in {self.server_dir}" + ) + if selection["downloads"]: removed_downloads = self.remove_directory_contents(self.downloads_dir) self.logger.log(f"Removed {removed_downloads} item(s) from {self.downloads_dir}") if selection["backups"]: if getattr(self.args, "cleanup_keep", None) is None: - removed_backups = self.remove_directory_contents(self.backups_dir) - self.logger.log(f"Removed {removed_backups} item(s) from {self.backups_dir}") + removed_backups = self.remove_directory_contents( + self.backups_dir, + excluded_paths=(self.jar_archive_dir,), + ) + self.logger.log( + f"Removed {removed_backups} legacy backup item(s) from {self.backups_dir}; " + f"managed jar archive {self.jar_archive_dir} was preserved" + ) else: removed_backups = self.trim_backups_to_keep(int(self.args.cleanup_keep)) self.logger.log( @@ -2127,6 +4251,7 @@ def run_cleanup(self) -> None: "Cleanup finished: " f"downloads={removed_downloads}, " f"backups={removed_backups}, " + f"server_jars_archived={archived_server_jars}, " f"metadata_cache={removed_metadata_cache}, " f"pycache={removed_pycache}, " f"json={removed_json}, " @@ -2138,17 +4263,14 @@ def run_cleanup(self) -> None: self.logger.log(summary) -def timestamp_for_filename() -> str: - return datetime.now().strftime("%Y%m%d-%H%M%S") - - def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="paperscript.sh", - description="Download and upgrade Paper server jars through the Fill v3 API.", + description="Manually stage verified, versioned Paper server jars through the Fill v3 API.", epilog=( f"Project and examples: {PROJECT_URL}\n" - "Use 'update' for the latest stable release, or 'download --version ... --build ...' for an exact jar." + "Use 'update' for the latest stable build in the launcher-selected family, or " + "'download --version ... --build ...' for an exact jar." ), ) parser.add_argument("--server-dir", help="Target server directory. Defaults to the current directory.") @@ -2158,7 +4280,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--tmux-session", - help="tmux session name to use for graceful stop. Defaults to config, PAPERSCRIPT_TMUX_SESSION, or mcserver.", + help="tmux session name to show in read-only status. Defaults to config, PAPERSCRIPT_TMUX_SESSION, or mcserver.", ) parser.add_argument( "--contact", @@ -2178,17 +4300,17 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--force", action="store_true", - help="Force reinstall even if the same build is already present. Useful with update, stable, experimental, or download.", + help="Allow the same build through selection again; existing jar targets are never overwritten.", ) parser.add_argument( "--dry-run", action="store_true", - help="Show what PaperScript would do without changing files or stopping servers.", + help="Show what PaperScript would do without downloading, moving, or pruning files.", ) parser.add_argument( "--quiet", action="store_true", - help="Suppress normal console output. Useful for cron or scheduled tasks; logs still go to logs.log.", + help="Suppress normal console output; logs still go to logs.log.", ) parser.add_argument( "--no-color", @@ -2208,8 +4330,11 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command") - subparsers.add_parser("update", help="Download the latest stable Paper build when appropriate.") - status_parser = subparsers.add_parser("status", help="Show current server state and whether an update is available.") + subparsers.add_parser("update", help="Stage the latest stable build for the launcher-selected Paper family.") + status_parser = subparsers.add_parser( + "status", + help="Show launcher, staged-JAR, retention, and release state.", + ) status_parser.add_argument( "--compact", dest="status_compact", @@ -2222,46 +4347,65 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Force the full status view even if config defaults to compact.", ) - subparsers.add_parser("verify", help="Verify the current jar SHA-256 against recorded state and the live API when available.") + subparsers.add_parser( + "verify", + help=( + "Fail closed unless the newest managed jar matches its recorded digests and " + "a fresh exact-build Paper API SHA-256." + ), + ) stable_parser = subparsers.add_parser( "stable", - help="Show or download the latest stable Paper release overall.", + help="Show and optionally stage the latest stable Paper release overall.", ) stable_parser.add_argument( "--download", action="store_true", - help="Download and install the latest stable release overall.", + help="Download, verify, and stage the latest stable release overall.", ) experimental_parser = subparsers.add_parser( "experimental", - help="Show or download the latest preview Paper release that is newer than the current stable release.", + help="Show and optionally stage the latest preview release newer than stable.", ) experimental_parser.add_argument( "--download", action="store_true", - help="Download and install the latest preview release that is newer than the current stable release.", + help="Download, verify, and stage the latest preview release that is newer than the current stable release.", ) cleanup_parser = subparsers.add_parser( "cleanup", - help="Remove selected runtime files such as downloads, backups, __pycache__, logs, or JSON state/config.", + help="Clean selected runtime files or explicitly archive older server-root Paper JARs.", ) cleanup_parser.add_argument( "--all", dest="cleanup_all", action="store_true", - help="Clean downloads, backups, __pycache__, logs, and JSON state/config together.", + help=( + "Clean downloads, legacy backups, metadata cache, __pycache__, logs, and JSON state/config. " + "The managed JAR archive and server-root JARs are preserved." + ), ) cleanup_parser.add_argument( "--downloads", dest="cleanup_downloads", action="store_true", - help="Delete staged downloads and temporary files in downloads/.", + help="Delete old workspace and temporary files in downloads/.", ) cleanup_parser.add_argument( "--backups", dest="cleanup_backups", action="store_true", - help="Delete all files in backups/, or trim them when used with --keep.", + help="Delete or trim legacy top-level backups while preserving the managed backups/jars/ archive.", + ) + cleanup_parser.add_argument( + "--server-jars", + dest="cleanup_server_jars", + action="store_true", + help=( + "Archive older exact Paper--.jar files from the server root while " + "protecting the launcher-marked jar, greatest numeric next-start jar, and any in-flight " + "launcher rollback jar. Never implied by --all." + ), ) cleanup_parser.add_argument( "--metadata-cache", @@ -2274,7 +4418,17 @@ def build_parser() -> argparse.ArgumentParser: dest="cleanup_keep", type=int, default=None, - help="When cleaning backups, keep the newest N backup files instead of deleting them all.", + help=( + "With --server-jars, keep a steady-state limit of N matching root jars (minimum 2); " + "an in-flight launcher rollback may temporarily add one. " + "Otherwise keep the newest N backup files." + ), + ) + cleanup_parser.add_argument( + "--version", + dest="cleanup_version", + default=None, + help="With --server-jars, manage only this Minecraft version; defaults to the launcher marker version.", ) cleanup_parser.add_argument( "--pycache", @@ -2318,15 +4472,18 @@ def build_parser() -> argparse.ArgumentParser: inspect_parser = subparsers.add_parser("inspect", help="Show the latest builds for one version.") inspect_parser.add_argument("version", help="Minecraft version to inspect, for example 26.2 or 1.20.4") - subparsers.add_parser("explore", help="Interactively pick a version, inspect it, and optionally download it.") + subparsers.add_parser("explore", help="Interactively pick a version, inspect it, and optionally stage it.") init_parser = subparsers.add_parser( "init", help="Create or repair the PaperScript runtime files in paperscript/ with confirmation.", ) - download_parser = subparsers.add_parser("download", help="Download a chosen version or exact build.") - download_parser.add_argument("--version", required=True, help="Minecraft version to download.") - download_parser.add_argument("--build", type=int, help="Exact build number to download.") + download_parser = subparsers.add_parser( + "download", + help="Download, verify, and stage a chosen version or exact build in the server root.", + ) + download_parser.add_argument("--version", required=True, help="Minecraft version to stage.") + download_parser.add_argument("--build", type=int, help="Exact build number to stage.") download_parser.add_argument( "--channel", default=None, @@ -2383,7 +4540,7 @@ def main() -> int: except PaperScriptError as error: print( color_text( - f"Error: {error}", + terminal_safe_text(f"Error: {error}"), ANSI_RED, supports_color(sys.stderr, no_color=bool(getattr(args, "no_color", False))), bold=True, diff --git a/paperscript/todo.log b/paperscript/todo.log index 761289f..ee85823 100644 --- a/paperscript/todo.log +++ b/paperscript/todo.log @@ -1,6 +1,89 @@ -PaperScript todo +PaperScript production todo -- Future automation: optional update scheduling or smarter unattended workflows. -- Future cleanup polish: extra selective cleanup and repair helpers beyond the current safe set. -- Future tmux/server control: start, stop, restart, and richer session management helpers. -- Future validation mode: additional smoke-test or planner mode beyond today's dry-run behavior. +Product direction and boundaries + +- [x] Keep PaperScript manually invoked. Remove cron, scheduling, and unattended-update recommendations. +- [x] Make update a non-disruptive staging operation: download and verify a new + Paper--.jar beside the active jar while the server keeps running. + PaperScript must not stop, kill, start, or restart the server, and must not replace + the jar currently in use. +- [x] Keep tmux lifecycle control manual and external to PaperScript. PaperScript may use + tmux information for read-only status and doctor checks, but update must not send + stop or start commands. +- [x] Keep full server, world, plugin, and BlueMap backup orchestration separate. A quick + Paper jar download must not wait for hundreds of gigabytes of backup work. Limit + PaperScript itself to safe jar retention and cleanup. + +Safe jar staging + +- [x] Add a per-server exclusive lock so overlapping manual runs cannot share, remove, + or overwrite each other's staged downloads or state. +- [x] Make staging transactional: require Paper API size and SHA-256, preflight server-root + write access, durable directory sync, and free-space reserve; stream into one private + same-filesystem inode without exceeding the declared size; verify on-disk size, + SHA-256, executable JAR structure, and every ZIP CRC; fsync; then atomically publish + Paper--.jar without overwriting an existing path. +- [x] Write config and state atomically with temp files, fsync, and os.replace; preserve + and report corrupt JSON instead of silently replacing it. +- [x] Write metadata cache atomically through validated directory descriptors and reject + unsafe cache directories and leaves. +- [ ] Preserve a corrupt cache for diagnosis before refreshing it. +- [x] Validate and contain every configured path. Reject filesystem roots, traversal, + unsafe symlinks, and jar filename patterns that are not plain .jar basenames. +- [x] Namespace runtime state and locks by canonical server directory so one PaperScript + checkout can safely target more than one server. + +1MB-minecraft.sh integration + +- [x] Copy the latest canonical 1MB-minecraft.sh into the disposable PaperScript test + instance and record its original SHA-256 for later manual comparison. +- [x] Customize the test-instance copy so, for its configured Minecraft version, it + recognizes Paper--.jar and selects the greatest numeric build + on the next manual start. Never select a jar from another Minecraft version. +- [x] Preserve compatibility with the existing paper-.jar naming fallback, ignore + .part and malformed files, handle case consistently, and use numeric rather than + lexicographic build ordering. +- [x] Add launcher tests for multiple builds, multiple Minecraft versions, legacy fallback, + malformed names, and build-number boundaries such as 9 versus 10. +- [x] Record the exact launcher-selected basename atomically before Java starts, resolve + the launcher's own directory, ignore symlink jar candidates, hold a fail-closed + launch lock for the JVM lifetime, and restore the prior marker on JVM failure. +- [ ] Manually review and apply the test-instance launcher diff at its canonical source, + then propagate it through the normal 1MB-minecraft.sh release/update flow rather than + editing every generated server copy. + +Bounded Paper jar retention + +- [x] Keep the valid last-launched jar plus the newest staged same-version jar in the + server root by default; temporarily protect one in-flight launcher rollback jar; + never infer or move an active jar without a valid marker. +- [x] Archive only exact regular non-symlink Paper--.jar files for + the marker version under paperscript/backups/jars// and cap that archive. +- [x] Add explicit cleanup --server-jars with confirmation, --keep, --version, and dry-run; + keep it out of cleanup --all and preserve the old cleanup --keep backup behavior. +- [x] Add adversarial tests for numeric ordering, versions, malformed names, symlinks, + invalid markers, archive caps, lock contention, and active-jar byte/inode stability. + +Validation and operator feedback + +- [ ] Add a read-only doctor command covering supported Java version, disk space, + permissions, API access, config/state validity, active and staged jars, and tmux + identity without controlling the tmux session. +- [x] Make verify fail closed: require a valid SHA-256 for production downloads and return + nonzero status for mismatch or unverifiable state. +- [ ] Add machine-readable JSON output and documented exit codes for current, update + available, staged, no-op, mismatch, degraded/API unavailable, and invalid config. +- [ ] Review the Codex Security report.md Security Objectives and Assumptions sections, + then reconcile accepted guarantees with repository documentation, tests, and deployment guidance. +- [ ] Add offline/degraded status with stale-cache fallback, log rotation and severity/run + IDs, a --version option, supported-Python CI, and reproducible tagged releases with + checksummed artifacts. + +Tests + +- [ ] Add hermetic CLI integration tests using a local HTTP fixture and disposable server + roots for download retries, corrupt responses/cache, checksum mismatch, and exits. +- [ ] Add failure-injection tests for concurrent runs, signals at every staging boundary, + full disks, path traversal and symlinks, corrupt JSON, and cross-filesystem paths. +- [ ] Add manual or scheduled project CI smoke coverage for Java/Paper compatibility without + turning server updates themselves into scheduled production automation. diff --git a/tests/live-smoke.sh b/tests/live-smoke.sh index 0ad1c0d..80d6a3b 100755 --- a/tests/live-smoke.sh +++ b/tests/live-smoke.sh @@ -35,10 +35,7 @@ config.update( "tmux_session": "paperscript-live-smoke", "default_channel": "STABLE", "check_latest_channel_only": "STABLE", - "allow_cross_version_auto_upgrade": False, "allow_same_version_build_upgrade": True, - "download_filename_pattern": "Paper-{version}.jar", - "running_server_action": "ask", "no_color": True, } ) @@ -65,35 +62,34 @@ root = Path(sys.argv[1]) runtime = root / "paperscript" config = json.loads((runtime / "config.json").read_text(encoding="utf-8")) state = json.loads((runtime / "state.json").read_text(encoding="utf-8")) -jar_path = root / state["current_jar"] -version_numbers = tuple(int(part) for part in state["current_version"].split(".")) +jar_path = root / state["staged_jar"] +version_numbers = tuple(int(part) for part in state["staged_version"].split(".")) assert config["default_channel"] == "STABLE" assert config["check_latest_channel_only"] == "STABLE" assert config["allow_same_version_build_upgrade"] is True -assert config["download_filename_pattern"] == "Paper-{version}.jar" -assert state["current_channel"] == "STABLE" +assert state["staged_channel"] == "STABLE" assert version_numbers >= (26, 2) -assert state["current_jar"] == f"Paper-{state['current_version']}.jar" +assert state["staged_jar"] == f"Paper-{state['staged_version']}-{state['staged_build']}.jar" assert jar_path.is_file() digest = hashlib.sha256(jar_path.read_bytes()).hexdigest() -assert digest == state["current_sha256"] +assert digest == state["staged_sha256"] assert digest == state["expected_sha256"] status = (root / "status.txt").read_text(encoding="utf-8") verify = (root / "verify.txt").read_text(encoding="utf-8") -assert f"version {state['current_version']}, build #{state['current_build']}" in status +assert f"version {state['staged_version']}, build #{state['staged_build']}" in status assert "channel STABLE" in status assert str(jar_path) in status -assert "already on the latest stable build" in status -assert "Recorded install channel: STABLE" in verify +assert "latest stable build for the launcher family is already staged" in status +assert "Recorded staging channel: STABLE" in verify assert "API channel: STABLE" in verify -assert "Current SHA-256 matches API expected SHA: yes" in verify +assert "Newest managed SHA-256 matches API expected SHA: yes" in verify print( "Live smoke passed: " - f"Paper {state['current_version']} build #{state['current_build']} " - f"({state['current_channel']}), {jar_path}, SHA-256 {digest}" + f"Paper {state['staged_version']} build #{state['staged_build']} " + f"({state['staged_channel']}), {jar_path}, SHA-256 {digest}" ) PY diff --git a/tests/test_1mb_minecraft.py b/tests/test_1mb_minecraft.py new file mode 100644 index 0000000..7c6efb3 --- /dev/null +++ b/tests/test_1mb_minecraft.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import os +import signal +import shutil +import subprocess +import tempfile +import time +import unittest +from dataclasses import dataclass +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +LAUNCHER_PATH = PROJECT_ROOT / "1MB-minecraft.sh" +PAPERSCRIPT_PATH = PROJECT_ROOT / "paperscript.sh" + + +@dataclass(frozen=True) +class LauncherRun: + completed: subprocess.CompletedProcess[str] + java_arguments: list[str] + marker_content: str | None + + +class OneMbMinecraftJarSelectionTests(unittest.TestCase): + def run_launcher( + self, + jar_names: list[str], + existing_marker: str | None = None, + invocation_cwd: Path | None = None, + jar_symlinks: dict[str, str] | None = None, + active_launcher_pid: int | None = None, + ownerless_launcher_lock: bool = False, + java_exit_code: int = 0, + ) -> LauncherRun: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + + fixture_root = Path(temporary.name) + server_dir = fixture_root / "server" + fake_bin = fixture_root / "fake-bin" + server_dir.mkdir() + fake_bin.mkdir() + paperscript_dir = server_dir / "paperscript" + paperscript_dir.mkdir() + marker_path = paperscript_dir / "last-launched-jar.txt" + if existing_marker is not None: + marker_path.write_text(existing_marker, encoding="utf-8") + if active_launcher_pid is not None or ownerless_launcher_lock: + launch_lock = paperscript_dir / "locks" / "server-launch" + launch_lock.mkdir(parents=True) + if active_launcher_pid is not None: + (launch_lock / "owner.pid").write_text( + f"{active_launcher_pid}\n", encoding="utf-8" + ) + + launcher = server_dir / LAUNCHER_PATH.name + shutil.copy2(LAUNCHER_PATH, launcher) + launcher.chmod(0o755) + for jar_name in jar_names: + (server_dir / jar_name).write_bytes(b"test jar placeholder") + for link_name, target_name in (jar_symlinks or {}).items(): + (server_dir / link_name).symlink_to(target_name) + + java_arguments = fixture_root / "java-arguments.txt" + fake_java = fake_bin / "java" + fake_java.write_text( + """#!/usr/bin/env bash +if [ "${1:-}" = "-version" ]; then + printf '%s\n' 'openjdk version "26.0.2"' >&2 + exit 0 +fi +printf '%s\n' "$@" >"${FAKE_JAVA_ARGS:?}" +exit "${FAKE_JAVA_EXIT:-0}" +""", + encoding="utf-8", + ) + fake_java.chmod(0o755) + + environment = dict(os.environ) + environment["FAKE_JAVA_ARGS"] = str(java_arguments) + environment["FAKE_JAVA_EXIT"] = str(java_exit_code) + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" + result = subprocess.run( + ["/bin/bash", str(launcher)], + cwd=invocation_cwd or server_dir, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + arguments = ( + java_arguments.read_text(encoding="utf-8").splitlines() + if java_arguments.exists() + else [] + ) + marker_content = ( + marker_path.read_text(encoding="utf-8").strip() + if marker_path.exists() + else None + ) + return LauncherRun( + completed=result, + java_arguments=arguments, + marker_content=marker_content, + ) + + def selected_jar(self, result: LauncherRun) -> str: + completed = result.completed + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + arguments = result.java_arguments + self.assertIn("-jar", arguments) + return arguments[arguments.index("-jar") + 1] + + def test_selects_greatest_numeric_build_for_configured_version(self) -> None: + result = self.run_launcher( + [ + "paper-26.2-9.jar", + "PAPER-26.2-10.JAR", + "Paper-26.2-latest.jar", + "Paper-26.2-11.jar.part", + "Paper-26.3-999.jar", + "Paper-26.2.jar", + ] + ) + + self.assertEqual(self.selected_jar(result), "PAPER-26.2-10.JAR") + self.assertEqual(result.marker_content, "PAPER-26.2-10.JAR") + + def test_uses_legacy_current_version_name_when_no_build_jar_exists(self) -> None: + result = self.run_launcher(["paper-26.2.jar", "Paper-26.3-999.jar"]) + + self.assertEqual(self.selected_jar(result), "paper-26.2.jar") + self.assertEqual(result.marker_content, "paper-26.2.jar") + + def test_never_selects_a_build_from_another_minecraft_version(self) -> None: + result = self.run_launcher(["Paper-26.3-999.jar"]) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertEqual(result.java_arguments, []) + self.assertIsNone(result.marker_content) + self.assertIn("Paper-26.2.jar", result.completed.stderr) + + def test_replaces_an_old_marker_before_java_starts(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar", "Paper-26.2-85.jar"], + existing_marker="Paper-26.2-84.jar\n", + ) + + self.assertEqual(self.selected_jar(result), "Paper-26.2-85.jar") + self.assertEqual(result.marker_content, "Paper-26.2-85.jar") + + def test_uses_the_launcher_directory_instead_of_the_callers_directory(self) -> None: + with tempfile.TemporaryDirectory() as outside: + result = self.run_launcher( + ["Paper-26.2-84.jar"], + invocation_cwd=Path(outside), + ) + + self.assertEqual(self.selected_jar(result), "Paper-26.2-84.jar") + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + + def test_ignores_a_higher_build_symlink(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar"], + jar_symlinks={"Paper-26.2-999.jar": "Paper-26.2-84.jar"}, + ) + + self.assertEqual(self.selected_jar(result), "Paper-26.2-84.jar") + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + + def test_concurrent_launcher_is_rejected_before_marker_changes(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar", "Paper-26.2-85.jar"], + existing_marker="Paper-26.2-84.jar\n", + active_launcher_pid=os.getpid(), + ) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertEqual(result.java_arguments, []) + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + self.assertIn("launch lock already exists", result.completed.stderr) + + def test_ownerless_launcher_lock_is_never_reclaimed_automatically(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar", "Paper-26.2-85.jar"], + existing_marker="Paper-26.2-84.jar\n", + ownerless_launcher_lock=True, + ) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertEqual(result.java_arguments, []) + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + self.assertIn("Refusing automatic recovery", result.completed.stderr) + + def test_dead_wrapper_pid_lock_is_never_reclaimed_automatically(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar", "Paper-26.2-85.jar"], + existing_marker="Paper-26.2-84.jar\n", + active_launcher_pid=99_999_999, + ) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertEqual(result.java_arguments, []) + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + self.assertIn("Refusing automatic recovery", result.completed.stderr) + + def test_failed_jvm_start_restores_the_previous_launcher_marker(self) -> None: + result = self.run_launcher( + ["Paper-26.2-84.jar", "Paper-26.2-85.jar"], + existing_marker="Paper-26.2-84.jar\n", + java_exit_code=1, + ) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertEqual(result.marker_content, "Paper-26.2-84.jar") + self.assertIn("Failed to start the jvm", result.completed.stderr) + + def test_first_failed_jvm_start_removes_its_unproven_marker(self) -> None: + result = self.run_launcher( + ["Paper-26.2-85.jar"], + java_exit_code=1, + ) + + self.assertNotEqual(result.completed.returncode, 0) + self.assertIsNone(result.marker_content) + + def test_live_retention_preserves_marker_rollback_until_failed_java_exits(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture_root = Path(directory) + server_dir = fixture_root / "server" + fake_bin = fixture_root / "fake-bin" + runtime = server_dir / "paperscript" + server_dir.mkdir() + fake_bin.mkdir() + runtime.mkdir() + marker = runtime / "last-launched-jar.txt" + marker.write_text("Paper-26.2-84.jar\n", encoding="utf-8") + for build in (84, 85): + (server_dir / f"Paper-26.2-{build}.jar").write_bytes( + f"build {build}".encode() + ) + + launcher = server_dir / LAUNCHER_PATH.name + shutil.copy2(LAUNCHER_PATH, launcher) + launcher.chmod(0o755) + release_java = fixture_root / "release-java" + child_pid_path = fixture_root / "java-child.pid" + fake_java = fake_bin / "java" + fake_java.write_text( + """#!/usr/bin/env bash +if [ "${1:-}" = "-version" ]; then + printf '%s\n' 'openjdk version "26.0.2"' >&2 + exit 0 +fi +printf '%s\n' "$$" >"${FAKE_JAVA_PID:?}" +while [ ! -e "${FAKE_JAVA_RELEASE:?}" ]; do + sleep 0.02 +done +exit 1 +""", + encoding="utf-8", + ) + fake_java.chmod(0o755) + + environment = dict(os.environ) + environment["FAKE_JAVA_PID"] = str(child_pid_path) + environment["FAKE_JAVA_RELEASE"] = str(release_java) + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" + launcher_error_path = fixture_root / "launcher.err" + process: subprocess.Popen[str] | None = None + try: + with launcher_error_path.open("w", encoding="utf-8") as launcher_error: + process = subprocess.Popen( + ["/bin/bash", str(launcher)], + cwd=server_dir, + env=environment, + stdout=subprocess.DEVNULL, + stderr=launcher_error, + text=True, + start_new_session=True, + ) + deadline = time.monotonic() + 5 + rollback_marker = runtime / ".launcher-marker-rollback" + while time.monotonic() < deadline: + if ( + child_pid_path.exists() + and marker.read_text(encoding="utf-8").strip() + == "Paper-26.2-85.jar" + and rollback_marker.exists() + ): + break + time.sleep(0.02) + else: + self.fail("launcher did not publish its active and rollback marker claims") + + cleanup = subprocess.run( + [ + str(PAPERSCRIPT_PATH), + "--server-dir", + str(server_dir), + "--yes", + "cleanup", + "--server-jars", + ], + cwd=PROJECT_ROOT, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=10, + ) + self.assertEqual( + cleanup.returncode, + 0, + cleanup.stdout + cleanup.stderr, + ) + self.assertTrue((server_dir / "Paper-26.2-84.jar").is_file()) + + release_java.touch() + process.wait(timeout=5) + + self.assertNotEqual(process.returncode, 0) + self.assertEqual( + marker.read_text(encoding="utf-8").strip(), + "Paper-26.2-84.jar", + ) + self.assertTrue((server_dir / "Paper-26.2-84.jar").is_file()) + self.assertFalse(rollback_marker.exists()) + finally: + if process is not None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + def test_wrapper_signal_preserves_lock_while_java_child_survives(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture_root = Path(directory) + server_dir = fixture_root / "server" + fake_bin = fixture_root / "fake-bin" + server_dir.mkdir() + fake_bin.mkdir() + (server_dir / "paperscript").mkdir() + (server_dir / "Paper-26.2-85.jar").write_bytes(b"test jar placeholder") + + launcher = server_dir / LAUNCHER_PATH.name + shutil.copy2(LAUNCHER_PATH, launcher) + launcher.chmod(0o755) + + child_pid_path = fixture_root / "java-child.pid" + fake_java = fake_bin / "java" + fake_java.write_text( + """#!/usr/bin/env bash +if [ "${1:-}" = "-version" ]; then + printf '%s\n' 'openjdk version "26.0.2"' >&2 + exit 0 +fi +printf '%s\n' "$$" >"${FAKE_JAVA_PID:?}" +exec sleep 300 +""", + encoding="utf-8", + ) + fake_java.chmod(0o755) + + environment = dict(os.environ) + environment["FAKE_JAVA_PID"] = str(child_pid_path) + environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" + first_error_path = fixture_root / "first-launch.err" + process: subprocess.Popen[str] | None = None + try: + with first_error_path.open("w", encoding="utf-8") as first_error: + process = subprocess.Popen( + ["/bin/bash", str(launcher)], + cwd=server_dir, + env=environment, + stdout=subprocess.DEVNULL, + stderr=first_error, + text=True, + start_new_session=True, + ) + deadline = time.monotonic() + 5 + launch_lock = server_dir / "paperscript" / "locks" / "server-launch" + while time.monotonic() < deadline: + if child_pid_path.exists() and launch_lock.is_dir(): + break + time.sleep(0.02) + else: + self.fail("blocking fake Java did not start and acquire the launch lock") + + os.kill(process.pid, signal.SIGTERM) + process.wait(timeout=5) + + child_pid = int(child_pid_path.read_text(encoding="utf-8").strip()) + os.kill(child_pid, 0) + self.assertTrue(launch_lock.is_dir()) + self.assertIn( + "lock was preserved", + first_error_path.read_text(encoding="utf-8"), + ) + + second = subprocess.run( + ["/bin/bash", str(launcher)], + cwd=server_dir, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=5, + ) + self.assertNotEqual(second.returncode, 0) + self.assertIn("launch lock already exists", second.stderr) + finally: + if process is not None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paperscript.py b/tests/test_paperscript.py index 1474e42..c1f7d0d 100644 --- a/tests/test_paperscript.py +++ b/tests/test_paperscript.py @@ -2,12 +2,15 @@ import argparse import importlib.util +import io import json import re import sys import tempfile import unittest +import zipfile from pathlib import Path +from unittest import mock PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -37,6 +40,45 @@ def build_info( ) +def executable_jar_bytes(payload: bytes = b"fixture") -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "META-INF/MANIFEST.MF", + "Manifest-Version: 1.0\r\nMain-Class: io.papermc.paperclip.Main\r\n\r\n", + ) + archive.writestr( + "io/papermc/paperclip/Main.class", + b"\xca\xfe\xba\xbe" + payload, + ) + archive.writestr("version.json", b"{}") + return output.getvalue() + + +def fill_build_payload(build: paperscript.BuildInfo) -> dict[str, object]: + return { + "builds": [ + { + "id": build.build_id, + "channel": build.channel, + "createdAt": build.created_at, + "downloads": { + "server:default": { + "name": build.download_name, + "url": build.download_url, + "checksums": {"sha256": build.sha256}, + "size": build.size, + } + }, + } + ] + } + + +def contains_terminal_control(text: str) -> bool: + return any(ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F for character in text) + + class RecordingLogger: def __init__(self) -> None: self.messages: list[str] = [] @@ -45,6 +87,360 @@ def log(self, message: str) -> None: self.messages.append(message) +class ByteResponse: + def __init__(self, content: bytes) -> None: + self.stream = io.BytesIO(content) + + def __enter__(self) -> "ByteResponse": + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + return None + + def read(self, size: int = -1) -> bytes: + return self.stream.read(size) + + +class PaperAPIDownloadSafetyTests(unittest.TestCase): + def make_api(self, retries: int = 0) -> paperscript.PaperAPI: + return paperscript.PaperAPI( + "test-agent/1.0 (https://example.invalid)", + retries=retries, + retry_backoff_seconds=0, + cache_enabled=False, + ) + + def test_download_never_writes_beyond_api_declared_size(self) -> None: + content = b"four" + build = build_info() + build.size = len(content) - 1 + build.sha256 = paperscript.hashlib.sha256(content[: build.size]).hexdigest() + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "private.part" + destination.write_bytes(b"") + + with mock.patch.object(paperscript, "urlopen", return_value=ByteResponse(content)): + with self.assertRaisesRegex(paperscript.PaperScriptError, "exceeded"): + self.make_api().download_file(build, destination) + + self.assertEqual(destination.stat().st_size, 0) + + def test_transient_retry_preserves_precreated_private_inode(self) -> None: + content = b"retried bytes" + build = build_info() + build.size = len(content) + build.sha256 = paperscript.hashlib.sha256(content).hexdigest() + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "private.part" + destination.write_bytes(b"") + before = destination.stat() + + with mock.patch.object( + paperscript, + "urlopen", + side_effect=[paperscript.URLError("temporary"), ByteResponse(content)], + ): + verification = self.make_api(retries=1).download_file(build, destination) + + after = destination.stat() + self.assertEqual((after.st_dev, after.st_ino), (before.st_dev, before.st_ino)) + self.assertEqual(destination.read_bytes(), content) + self.assertEqual(verification.bytes_written, len(content)) + + def test_descriptor_download_cannot_be_redirected_by_path_replacement(self) -> None: + content = b"descriptor bytes" + build = build_info() + build.size = len(content) + build.sha256 = paperscript.hashlib.sha256(content).hexdigest() + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "private.part" + destination.write_bytes(b"") + descriptor = paperscript.os.open(destination, paperscript.os.O_RDWR) + try: + destination.unlink() + destination.write_bytes(b"replacement sentinel") + + with mock.patch.object(paperscript, "urlopen", return_value=ByteResponse(content)): + verification = self.make_api().download_file( + build, + destination, + descriptor, + ) + + paperscript.os.lseek(descriptor, 0, paperscript.os.SEEK_SET) + self.assertEqual(paperscript.os.read(descriptor, len(content)), content) + self.assertEqual(destination.read_bytes(), b"replacement sentinel") + self.assertEqual(verification.sha256, build.sha256) + finally: + paperscript.os.close(descriptor) + + +class PaperAPICacheSafetyTests(unittest.TestCase): + def make_api(self, directory: str) -> paperscript.PaperAPI: + return paperscript.PaperAPI( + "test-agent/1.0 (https://example.invalid)", + cache_dir=Path(directory) / "cache", + cache_ttl_seconds=300, + cache_enabled=True, + ) + + def write_cache( + self, + api: paperscript.PaperAPI, + label: str, + cached_at_epoch: object, + data: object, + ) -> Path: + path = api._cache_path(label) + if path is None: + raise AssertionError("cache unexpectedly disabled") + path.write_text( + json.dumps( + { + "cached_at": "fixture", + "cached_at_epoch": cached_at_epoch, + "data": data, + } + ) + + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + return path + + def test_future_cache_cannot_override_fresh_build_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + forged = build_info() + forged.download_url = "https://attacker.invalid/forged.jar" + forged.sha256 = "f" * 64 + authoritative = build_info() + authoritative.download_url = "https://downloads.example.invalid/authentic.jar" + authoritative.sha256 = "b" * 64 + self.write_cache( + api, + "builds-26.2", + paperscript.time.time() + 86_400, + fill_build_payload(forged), + ) + + with mock.patch.object( + api, + "_request_json", + return_value=fill_build_payload(authoritative), + ) as request_json: + selected = api.get_builds("26.2") + + request_json.assert_called_once() + self.assertEqual(selected[0].download_url, authoritative.download_url) + self.assertEqual(selected[0].sha256, authoritative.sha256) + + def test_invalid_cache_timestamps_are_rejected(self) -> None: + invalid_timestamps = ( + float("nan"), + float("inf"), + float("-inf"), + True, + 10**400, + ) + for cached_at in invalid_timestamps: + with self.subTest(cached_at=cached_at), tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + self.write_cache(api, "versions", cached_at, {"sentinel": True}) + self.assertIsNone(api._load_cache("versions")) + + def test_cache_load_rejects_symlink_and_group_writable_leaf(self) -> None: + with tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + cache_path = api._cache_path("versions") + if cache_path is None: + raise AssertionError("cache unexpectedly disabled") + victim = Path(directory) / "victim.json" + victim.write_text( + json.dumps( + { + "cached_at_epoch": paperscript.time.time(), + "data": {"sentinel": True}, + } + ), + encoding="utf-8", + ) + cache_path.symlink_to(victim) + + self.assertIsNone(api._load_cache("versions")) + + cache_path.unlink() + self.write_cache( + api, + "versions", + paperscript.time.time(), + {"sentinel": True}, + ).chmod(0o660) + self.assertIsNone(api._load_cache("versions")) + + def test_cache_is_disabled_for_group_writable_or_symlinked_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + cache_dir = Path(directory) / "cache" + cache_dir.mkdir(mode=0o700) + cache_dir.chmod(0o770) + self.assertIsNone(api._cache_path("versions")) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + redirected = root / "redirected" + redirected.mkdir() + (root / "cache").symlink_to(redirected, target_is_directory=True) + api = self.make_api(directory) + self.assertIsNone(api._cache_path("versions")) + + def test_cache_save_replaces_symlink_without_touching_target(self) -> None: + with tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + cache_path = api._cache_path("versions") + if cache_path is None: + raise AssertionError("cache unexpectedly disabled") + victim = Path(directory) / "victim.json" + victim.write_text("do not overwrite\n", encoding="utf-8") + cache_path.symlink_to(victim) + + api._save_cache("versions", {"fresh": True}) + + self.assertEqual(victim.read_text(encoding="utf-8"), "do not overwrite\n") + self.assertTrue(cache_path.is_file()) + self.assertFalse(cache_path.is_symlink()) + self.assertEqual(cache_path.stat().st_mode & 0o777, 0o600) + self.assertEqual(json.loads(cache_path.read_text(encoding="utf-8"))["data"], {"fresh": True}) + + cache_path.unlink() + missing_target = Path(directory) / "must-not-be-created.json" + cache_path.symlink_to(missing_target) + api._save_cache("versions", {"second": True}) + self.assertFalse(missing_target.exists()) + self.assertTrue(cache_path.is_file()) + self.assertFalse(cache_path.is_symlink()) + + def test_cache_save_stays_on_open_directory_when_path_is_swapped(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + api = self.make_api(directory) + cache_path = api._cache_path("versions") + if cache_path is None: + raise AssertionError("cache unexpectedly disabled") + original_cache_dir = root / "cache" + retained_cache_dir = root / "retained-cache" + redirected_dir = root / "redirected" + redirected_dir.mkdir() + real_atomic_write = paperscript.atomic_write_cache_text + + def swap_directory_before_write( + directory_descriptor: int, + leaf_name: str, + content: str, + mode: int = 0o600, + ) -> None: + original_cache_dir.rename(retained_cache_dir) + original_cache_dir.symlink_to(redirected_dir, target_is_directory=True) + real_atomic_write(directory_descriptor, leaf_name, content, mode) + + with mock.patch.object( + paperscript, + "atomic_write_cache_text", + side_effect=swap_directory_before_write, + ): + api._save_cache("versions", {"fresh": True}) + + self.assertFalse((redirected_dir / "versions.json").exists()) + retained_payload = json.loads( + (retained_cache_dir / "versions.json").read_text(encoding="utf-8") + ) + self.assertEqual(retained_payload["data"], {"fresh": True}) + + def test_build_lookup_rejects_unsafe_version_before_request(self) -> None: + with tempfile.TemporaryDirectory() as directory: + api = self.make_api(directory) + with mock.patch.object(api, "_request_json") as request_json: + with self.assertRaisesRegex(paperscript.PaperScriptError, "Unsafe Paper version"): + api.get_builds("26.2/../../outside", use_cache=False) + request_json.assert_not_called() + + +class TerminalOutputSafetyTests(unittest.TestCase): + def test_terminal_safe_text_escapes_every_control_range(self) -> None: + untrusted = "prefix\x1b[31mred\x1b]52;c;secret\x07\nnext\rline\tend\b\x00\x7f\x85" + + rendered = paperscript.terminal_safe_text(untrusted) + + self.assertFalse(contains_terminal_control(rendered)) + for visible_escape in (r"\x1b", r"\x07", r"\n", r"\r", r"\t", r"\x08", r"\x00", r"\x7f", r"\x85"): + self.assertIn(visible_escape, rendered) + + def test_http_detail_and_logger_neutralize_multiline_terminal_payloads(self) -> None: + untrusted = "API\x1b]8;;https://attacker.invalid\x07link\x1b]8;;\x07\nsecond line\rreset" + summary = paperscript.summarize_http_detail(untrusted) + self.assertFalse(contains_terminal_control(summary)) + self.assertIn(r"\x1b", summary) + self.assertIn(r"\x07", summary) + + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "logs.log" + logger = paperscript.Logger(log_path, use_color=False) + with mock.patch("builtins.print") as print_message: + logger.log(untrusted) + + console_text = print_message.call_args.args[0] + durable_text = log_path.read_text(encoding="utf-8") + self.assertFalse(contains_terminal_control(console_text)) + self.assertFalse(contains_terminal_control(durable_text.rstrip("\n"))) + self.assertEqual(len(durable_text.splitlines()), 1) + self.assertIn(r"\x1b", console_text) + self.assertIn(r"\n", durable_text) + + def test_owned_colors_survive_while_untrusted_escape_sequences_do_not(self) -> None: + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "logs.log" + logger = paperscript.Logger(log_path, use_color=True) + app = object.__new__(paperscript.PaperScriptApp) + app.logger = logger + malicious_value = "release\x1b]52;c;secret\x07" + + styled = app.format_browser_entry(" - ", malicious_value, " (stable)") + + self.assertIsInstance(styled, paperscript.StyledConsoleText) + with mock.patch("builtins.print") as print_message: + logger.log(styled) + + console_text = print_message.call_args.args[0] + durable_text = log_path.read_text(encoding="utf-8") + self.assertIn("\x1b[", console_text) + self.assertNotIn("\x1b]52", console_text) + self.assertIn(r"\x1b]52", console_text) + self.assertFalse(contains_terminal_control(durable_text.rstrip("\n"))) + self.assertNotIn("\x1b[", durable_text) + self.assertIn(r"\x1b]52", durable_text) + + def test_top_level_stderr_neutralizes_remote_exception_text(self) -> None: + untrusted = "failure\x1b]52;c;secret\x07\nforged line\rreturn\b" + stderr = io.StringIO() + with mock.patch.object( + paperscript, + "PaperScriptApp", + side_effect=paperscript.PaperScriptError(untrusted), + ), mock.patch.object( + sys, + "argv", + ["paperscript.py", "--no-color", "status"], + ), mock.patch.object(sys, "stderr", stderr): + exit_code = paperscript.main() + + rendered = stderr.getvalue() + self.assertEqual(exit_code, 1) + self.assertFalse(contains_terminal_control(rendered.rstrip("\n"))) + self.assertEqual(len(rendered.splitlines()), 1) + self.assertIn(r"\x1b", rendered) + self.assertIn(r"\n", rendered) + + class ReleaseAndConfigTests(unittest.TestCase): def test_release_metadata_format(self) -> None: self.assertRegex(paperscript.APP_VERSION, r"^\d+\.\d+\.\d+$") @@ -67,16 +463,214 @@ def test_tracked_and_documented_config_match_runtime_defaults(self) -> None: marker = "Current default config:\n\n```json\n" documented_json = readme.split(marker, 1)[1].split("\n```", 1)[0] self.assertEqual(json.loads(documented_json), paperscript.DEFAULT_CONFIG) + self.assertEqual( + (PROJECT_ROOT / "paperscript" / "todo.log").read_text(encoding="utf-8"), + paperscript.TODO_TEMPLATE, + ) def test_stable_update_defaults_remain_safe(self) -> None: self.assertEqual(paperscript.DEFAULT_CONFIG["default_channel"], "STABLE") self.assertEqual(paperscript.DEFAULT_CONFIG["check_latest_channel_only"], "STABLE") self.assertTrue(paperscript.DEFAULT_CONFIG["allow_same_version_build_upgrade"]) - self.assertFalse(paperscript.DEFAULT_CONFIG["allow_cross_version_auto_upgrade"]) - self.assertEqual( - paperscript.DEFAULT_CONFIG["download_filename_pattern"], - "Paper-{version}-{build}.jar", + self.assertNotIn("allow_cross_version_auto_upgrade", paperscript.DEFAULT_CONFIG) + self.assertNotIn("download_filename_pattern", paperscript.DEFAULT_CONFIG) + + def test_malformed_config_is_preserved_and_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "config.json" + malformed = '{"keep_server_jars": ' + config_path.write_text(malformed, encoding="utf-8") + app = object.__new__(paperscript.PaperScriptApp) + app.config_path = config_path + + with self.assertRaises(paperscript.PaperScriptError): + app._load_config() + + self.assertEqual(config_path.read_text(encoding="utf-8"), malformed) + + def test_deprecated_destructive_config_keys_are_removed_during_migration(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "config.json" + config_path.write_text( + json.dumps( + { + "server_name": "production", + "running_server_action": "force-stop", + "download_filename_pattern": "Paper-{version}.jar", + } + ), + encoding="utf-8", + ) + app = object.__new__(paperscript.PaperScriptApp) + app.config_path = config_path + + migrated = app._load_config() + + self.assertEqual(migrated["server_name"], "production") + self.assertNotIn("running_server_action", migrated) + self.assertNotIn("download_filename_pattern", migrated) + saved = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual(saved, migrated) + + def test_invalid_config_value_types_are_preserved_and_rejected(self) -> None: + invalid_values = { + "quiet": "false", + "keep_server_jars": "two", + "keep_archived_jars": 0, + "http_retry_backoff_seconds": -1, + } + for key, value in invalid_values.items(): + with self.subTest(key=key), tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "config.json" + raw = json.dumps({key: value}) + "\n" + config_path.write_text(raw, encoding="utf-8") + app = object.__new__(paperscript.PaperScriptApp) + app.config_path = config_path + + with self.assertRaisesRegex(paperscript.PaperScriptError, key): + app._load_config() + + self.assertEqual(config_path.read_text(encoding="utf-8"), raw) + + def test_nonfinite_and_unknown_config_values_are_preserved_and_rejected(self) -> None: + invalid_configs = ( + {"http_retry_backoff_seconds": float("nan")}, + {"http_retry_backoff_seconds": float("inf")}, + {"http_retires": 5}, + ) + for config in invalid_configs: + with self.subTest(config=config), tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "config.json" + raw = json.dumps(config) + "\n" + config_path.write_text(raw, encoding="utf-8") + app = object.__new__(paperscript.PaperScriptApp) + app.config_path = config_path + + with self.assertRaises(paperscript.PaperScriptError): + app._load_config() + + self.assertEqual(config_path.read_text(encoding="utf-8"), raw) + + def test_runtime_roles_cannot_alias_archive_state_log_or_each_other(self) -> None: + invalid_configs = ( + {"downloads_dir": "backups/jars"}, + {"metadata_cache_dir": "backups"}, + {"log_file": "config.json"}, + {"log_file": "var", "downloads_dir": "var/downloads"}, + {"downloads_dir": "locks"}, ) + for config in invalid_configs: + with self.subTest(config=config), tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "config.json" + raw = json.dumps(config) + "\n" + config_path.write_text(raw, encoding="utf-8") + app = object.__new__(paperscript.PaperScriptApp) + app.config_path = config_path + + with self.assertRaises(paperscript.PaperScriptError): + app._load_config() + + self.assertEqual(config_path.read_text(encoding="utf-8"), raw) + + def test_server_dir_namespaces_runtime_config_and_state(self) -> None: + with tempfile.TemporaryDirectory() as directory: + first_server = Path(directory) / "first" + second_server = Path(directory) / "second" + parser = paperscript.build_parser() + first_args = parser.parse_args( + ["--server-dir", str(first_server), "status"] + ) + second_args = parser.parse_args( + ["--server-dir", str(second_server), "status"] + ) + + first = paperscript.PaperScriptApp(first_args) + first.state["sentinel"] = "first" + first._save_json(first.state_path, first.state) + second = paperscript.PaperScriptApp(second_args) + + self.assertEqual(first.runtime_dir, first_server.resolve() / "paperscript") + self.assertEqual(second.runtime_dir, second_server.resolve() / "paperscript") + self.assertNotEqual(first.config_path, second.config_path) + self.assertNotEqual(first.state_path, second.state_path) + self.assertEqual(second.state, {}) + + def test_central_checkout_runtime_files_trigger_target_migration_warning(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fake_script_dir = root / "central-checkout" / "paperscript" + target = root / "server" + fake_script_dir.mkdir(parents=True) + (fake_script_dir / "paperscript.py").write_text("# fixture\n", encoding="utf-8") + legacy_config = fake_script_dir / "config.json" + legacy_state = fake_script_dir / "state.json" + legacy_config.write_text( + json.dumps({"server_name": "legacy central config"}) + "\n", + encoding="utf-8", + ) + legacy_state.write_text( + json.dumps({"server_dir": str(target)}) + "\n", + encoding="utf-8", + ) + args = paperscript.build_parser().parse_args( + ["--server-dir", str(target), "status"] + ) + + with mock.patch.object( + paperscript, + "__file__", + str(fake_script_dir / "paperscript.py"), + ): + app = paperscript.PaperScriptApp(args) + + self.assertEqual(app.runtime_dir, target.resolve() / "paperscript") + self.assertEqual(app.config["server_name"], None) + self.assertEqual( + json.loads(legacy_config.read_text(encoding="utf-8"))["server_name"], + "legacy central config", + ) + self.assertIn( + "Legacy central-checkout runtime file(s) were not reused", + app.log_path.read_text(encoding="utf-8"), + ) + + def test_runtime_paths_reject_absolute_parent_and_symlink_escapes(self) -> None: + parser = paperscript.build_parser() + for configured_path in ("/tmp/paperscript-outside", "../outside"): + with self.subTest(path=configured_path), tempfile.TemporaryDirectory() as directory: + server_dir = Path(directory) / "server" + runtime_dir = server_dir / "paperscript" + runtime_dir.mkdir(parents=True) + config_path = runtime_dir / "config.json" + raw = json.dumps({"backup_dir": configured_path}) + "\n" + config_path.write_text(raw, encoding="utf-8") + args = parser.parse_args( + ["--server-dir", str(server_dir), "status"] + ) + + with self.assertRaises(paperscript.PaperScriptError): + paperscript.PaperScriptApp(args) + + self.assertEqual(config_path.read_text(encoding="utf-8"), raw) + + with tempfile.TemporaryDirectory() as directory: + server_dir = Path(directory) / "server" + runtime_dir = server_dir / "paperscript" + outside = Path(directory) / "outside" + runtime_dir.mkdir(parents=True) + outside.mkdir() + (runtime_dir / "downloads").symlink_to(outside, target_is_directory=True) + args = parser.parse_args(["--server-dir", str(server_dir), "status"]) + + with self.assertRaises(paperscript.PaperScriptError): + paperscript.PaperScriptApp(args) + + def test_filesystem_root_cannot_be_a_server_directory(self) -> None: + parser = paperscript.build_parser() + args = parser.parse_args(["--server-dir", "/", "status"]) + + with self.assertRaisesRegex(paperscript.PaperScriptError, "filesystem root"): + paperscript.PaperScriptApp(args) class VersionAndArgumentTests(unittest.TestCase): @@ -143,10 +737,1531 @@ def test_recorded_install_state_includes_channel(self) -> None: app.record_state(build_info(), jar_path, "b" * 64) saved = json.loads(app.state_path.read_text(encoding="utf-8")) - self.assertEqual(saved["current_channel"], "STABLE") - self.assertEqual(saved["current_jar"], "Paper-26.2.jar") - self.assertEqual(saved["current_version"], "26.2") - self.assertEqual(saved["current_build"], 84) + self.assertEqual(saved["staged_channel"], "STABLE") + self.assertEqual(saved["staged_jar"], "Paper-26.2.jar") + self.assertEqual(saved["staged_version"], "26.2") + self.assertEqual(saved["staged_build"], 84) + self.assertNotIn("current_jar", saved) + + +class VerifyCommandTests(unittest.TestCase): + def make_app( + self, + directory: str, + *, + content: bytes = b"authentic Paper jar bytes", + filename: str = "Paper-26.2-84.jar", + state: dict[str, object] | None = None, + ) -> tuple[paperscript.PaperScriptApp, Path, paperscript.BuildInfo]: + jar_path = Path(directory) / filename + jar_path.write_bytes(content) + app = object.__new__(paperscript.PaperScriptApp) + app.server_dir = Path(directory) + app.server_runtime_dir = app.server_dir / "paperscript" + app.server_lock_path = app.server_runtime_dir / "locks" / "paper-jars.lock" + app.state = dict(state or {}) + app.logger = RecordingLogger() + authoritative = build_info() + authoritative.sha256 = paperscript.hashlib.sha256(content).hexdigest() + app.api = mock.Mock() + app.api.get_build_by_id.return_value = authoritative + return app, jar_path, authoritative + + def test_success_requires_fresh_exact_api_checksum(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, jar_path, authoritative = self.make_app(directory) + authoritative.sha256 = str(authoritative.sha256).upper() + app.state = { + "staged_jar": jar_path.name, + "staged_sha256": authoritative.sha256, + "expected_sha256": authoritative.sha256, + } + + app.run_verify() + + app.api.get_build_by_id.assert_called_once_with( + "26.2", + 84, + use_cache=False, + ) + self.assertIn( + "Verification succeeded: newest managed jar matches the fresh Paper API SHA-256.", + app.logger.messages, + ) + + def test_api_outage_fails_instead_of_accepting_local_state(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, jar_path, authoritative = self.make_app(directory) + app.state = { + "staged_jar": jar_path.name, + "staged_sha256": authoritative.sha256, + "expected_sha256": authoritative.sha256, + } + app.api.get_build_by_id.side_effect = paperscript.PaperScriptError( + "simulated Paper API outage" + ) + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "Fresh API checksum lookup failed", + ): + app.run_verify() + + def test_missing_exact_api_build_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, _, _ = self.make_app(directory) + app.api.get_build_by_id.return_value = None + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "exact build.*was not found", + ): + app.run_verify() + + def test_missing_or_malformed_api_checksum_fails(self) -> None: + invalid_values: tuple[object, ...] = ( + None, + "", + "a" * 63, + "g" * 64, + 123, + ) + for invalid in invalid_values: + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + app, _, authoritative = self.make_app(directory) + authoritative.sha256 = invalid # type: ignore[assignment] + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "valid SHA-256", + ): + app.run_verify() + + def test_api_checksum_mismatch_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, _, authoritative = self.make_app(directory) + authoritative.sha256 = "f" * 64 + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "does not match the fresh Paper API SHA-256", + ): + app.run_verify() + + def test_forged_warm_cache_cannot_authorize_checksum(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, _, cached = self.make_app(directory) + api = paperscript.PaperAPI( + "test-agent/1.0 (https://example.invalid)", + cache_dir=app.server_runtime_dir / "cache", + cache_ttl_seconds=300, + cache_enabled=True, + ) + api._save_cache("builds-26.2", fill_build_payload(cached)) + fresh = build_info() + fresh.sha256 = "f" * 64 + app.api = api + + with mock.patch.object( + api, + "_request_json", + return_value=fill_build_payload(fresh), + ) as request_json: + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "does not match the fresh Paper API SHA-256", + ): + app.run_verify() + + request_json.assert_called_once_with( + f"{paperscript.API_ROOT}/versions/26.2/builds" + ) + + def test_matching_target_state_digest_claims_fail_independently(self) -> None: + for field in ("staged_sha256", "expected_sha256"): + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + app, jar_path, authoritative = self.make_app(directory) + app.state = { + "staged_jar": jar_path.name, + "staged_sha256": authoritative.sha256, + "expected_sha256": authoritative.sha256, + field: "f" * 64, + } + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "recorded .* SHA-256", + ): + app.run_verify() + + def test_malformed_matching_target_state_digest_fails(self) -> None: + for invalid in ("not-a-digest", 0, False): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + app, jar_path, _ = self.make_app(directory) + app.state = { + "staged_jar": jar_path.name, + "staged_sha256": invalid, + } + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "recorded staging SHA-256 is invalid", + ): + app.run_verify() + + def test_optional_or_unrelated_state_does_not_replace_fresh_api_trust(self) -> None: + states = ( + {}, + { + "staged_jar": "Paper-26.1-1.jar", + "staged_sha256": "f" * 64, + }, + { + "staged_jar": "Paper-26.2-84.jar", + "staged_channel": "BETA", + }, + ) + for state in states: + with self.subTest(state=state), tempfile.TemporaryDirectory() as directory: + app, _, _ = self.make_app(directory, state=state) + + app.run_verify() + + app.api.get_build_by_id.assert_called_once_with( + "26.2", + 84, + use_cache=False, + ) + + def test_legacy_state_identified_jar_remains_verifiable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + state = { + "current_jar": "Paper-26.2.jar", + "current_version": "26.2", + "current_build": 84, + "current_channel": "STABLE", + } + app, _, _ = self.make_app( + directory, + filename="Paper-26.2.jar", + state=state, + ) + + app.run_verify() + + app.api.get_build_by_id.assert_called_once_with( + "26.2", + 84, + use_cache=False, + ) + + def test_path_replacement_during_verification_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, jar_path, _ = self.make_app(directory) + original_sha256_descriptor = paperscript.sha256_descriptor + + def replace_path_after_hash(descriptor: int) -> str: + digest = original_sha256_descriptor(descriptor) + jar_path.rename(jar_path.with_suffix(".replaced")) + jar_path.write_bytes(b"replacement bytes") + return digest + + with mock.patch.object( + paperscript, + "sha256_descriptor", + side_effect=replace_path_after_hash, + ): + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "changed identity", + ): + app.run_verify() + + app.api.get_build_by_id.assert_not_called() + + def test_path_replacement_during_fresh_api_lookup_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, jar_path, authoritative = self.make_app(directory) + + def replace_path_during_lookup(*args: object, **kwargs: object) -> paperscript.BuildInfo: + jar_path.rename(jar_path.with_suffix(".replaced")) + jar_path.write_bytes(b"replacement bytes") + return authoritative + + app.api.get_build_by_id.side_effect = replace_path_during_lookup + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "changed identity", + ): + app.run_verify() + + def test_in_place_mutation_during_fresh_api_lookup_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, jar_path, authoritative = self.make_app(directory) + + def mutate_during_lookup(*args: object, **kwargs: object) -> paperscript.BuildInfo: + jar_path.write_bytes(b"mutated in place") + return authoritative + + app.api.get_build_by_id.side_effect = mutate_during_lookup + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "changed identity or contents", + ): + app.run_verify() + + def test_newer_managed_jar_appearing_during_lookup_fails(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, _, authoritative = self.make_app(directory) + + def publish_newer_during_lookup(*args: object, **kwargs: object) -> paperscript.BuildInfo: + (app.server_dir / "Paper-26.2-85.jar").write_bytes(b"unverified newer jar") + return authoritative + + app.api.get_build_by_id.side_effect = publish_newer_during_lookup + + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "no longer the newest managed jar", + ): + app.run_verify() + + def test_verify_fails_while_server_mutation_lock_is_held(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, _, _ = self.make_app(directory) + + with app.server_mutation_lock(): + with self.assertRaisesRegex( + paperscript.PaperScriptError, + "already running", + ): + app.run_verify() + + app.api.get_build_by_id.assert_not_called() + + def test_main_maps_verification_failure_to_nonzero_status(self) -> None: + stderr = io.StringIO() + with mock.patch.object( + paperscript, + "PaperScriptApp", + ) as app_class, mock.patch.object( + sys, + "argv", + ["paperscript.py", "--no-color", "verify"], + ), mock.patch.object(sys, "stderr", stderr): + app_class.return_value.run_verify.side_effect = paperscript.PaperScriptError( + "verification failed" + ) + + exit_code = paperscript.main() + + self.assertEqual(exit_code, 1) + self.assertIn("verification failed", stderr.getvalue()) + + +class ServerJarRetentionTests(unittest.TestCase): + def make_app(self, directory: str) -> paperscript.PaperScriptApp: + app = object.__new__(paperscript.PaperScriptApp) + app.server_dir = Path(directory) + app.server_runtime_dir = app.server_dir / "paperscript" + app.last_launched_jar_marker_path = ( + app.server_runtime_dir / "last-launched-jar.txt" + ) + app.jar_archive_dir = app.server_runtime_dir / "backups" / "jars" + app.state_path = app.server_runtime_dir / "state.json" + app.state = {} + app.keep_server_jars = 2 + app.keep_archived_jars = 5 + app.logger = RecordingLogger() + return app + + def write_marker(self, app: paperscript.PaperScriptApp, name: str) -> None: + app.last_launched_jar_marker_path.parent.mkdir(parents=True, exist_ok=True) + app.last_launched_jar_marker_path.write_text(name + "\n", encoding="utf-8") + + def test_keeps_last_launched_and_newest_build_and_archives_the_middle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + for build in (9, 10, 11): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes( + f"build {build}".encode() + ) + self.write_marker(app, "Paper-26.2-9.jar") + + plan = app.plan_server_jar_retention("26.2", keep=2) + + self.assertEqual( + {path.name for path in plan.kept}, + {"Paper-26.2-9.jar", "Paper-26.2-11.jar"}, + ) + self.assertEqual( + [path.name for path in plan.to_archive], + ["Paper-26.2-10.jar"], + ) + + app.apply_server_jar_retention(plan) + + self.assertFalse((app.server_dir / "Paper-26.2-10.jar").exists()) + self.assertTrue( + (app.jar_archive_dir / "26.2" / "Paper-26.2-10.jar").is_file() + ) + + def test_only_exact_same_version_regular_jars_are_managed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + active = app.server_dir / "Paper-26.2-9.jar" + newest = app.server_dir / "Paper-26.2-11.jar" + middle = app.server_dir / "paper-26.2-10.JAR" + for path in (active, newest, middle): + path.write_bytes(path.name.encode()) + untouched_names = [ + "Paper-26.3-999.jar", + "Paper-26.2.jar", + "Paper-26.2-latest.jar", + "Paper-26.2-12.jar.part", + "Spigot-26.2-1000.jar", + "unrelated.jar", + ] + for name in untouched_names: + (app.server_dir / name).write_bytes(name.encode()) + outside = app.server_dir / "outside.jar" + outside.write_bytes(b"outside") + symlink = app.server_dir / "Paper-26.2-1000.jar" + symlink.symlink_to(outside) + self.write_marker(app, active.name) + + plan = app.plan_server_jar_retention("26.2", keep=2) + app.apply_server_jar_retention(plan) + + self.assertEqual([path.name for path in plan.to_archive], [middle.name]) + for name in untouched_names: + self.assertTrue((app.server_dir / name).is_file(), name) + self.assertTrue(symlink.is_symlink()) + self.assertEqual(outside.read_bytes(), b"outside") + + def test_plan_uses_actual_next_launcher_choice_when_recorded_stage_is_stale(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + for build in (8, 9, 10): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes(b"paper") + self.write_marker(app, "Paper-26.2-9.jar") + staged = app.server_dir / "Paper-26.2-8.jar" + + plan = app.plan_server_jar_retention( + "26.2", + keep=2, + staged_path=staged, + ) + + self.assertEqual( + {path.name for path in plan.kept}, + { + "Paper-26.2-9.jar", + "Paper-26.2-10.jar", + }, + ) + self.assertEqual( + [path.name for path in plan.to_archive], + ["Paper-26.2-8.jar"], + ) + + def test_active_launcher_rollback_claim_is_protected_during_retention(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + for build in (84, 85, 86, 87): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes( + f"build {build}".encode() + ) + self.write_marker(app, "Paper-26.2-85.jar") + launch_lock = app.server_runtime_dir / "locks" / "server-launch" + launch_lock.mkdir(parents=True) + (launch_lock / "owner.pid").write_text("12345\n", encoding="utf-8") + (app.server_runtime_dir / paperscript.LAUNCHER_MARKER_ROLLBACK).write_text( + "Paper-26.2-84.jar\n", + encoding="utf-8", + ) + + plan = app.plan_server_jar_retention("26.2", keep=2) + + self.assertEqual( + {path.name for path in plan.kept}, + { + "Paper-26.2-84.jar", + "Paper-26.2-85.jar", + "Paper-26.2-87.jar", + }, + ) + self.assertEqual(plan.launch_rollback.name, "Paper-26.2-84.jar") + self.assertEqual( + [path.name for path in plan.to_archive], + ["Paper-26.2-86.jar"], + ) + + app.apply_server_jar_retention(plan) + + self.assertTrue((app.server_dir / "Paper-26.2-84.jar").is_file()) + self.assertTrue((app.server_dir / "Paper-26.2-85.jar").is_file()) + self.assertTrue((app.server_dir / "Paper-26.2-87.jar").is_file()) + self.assertFalse((app.server_dir / "Paper-26.2-86.jar").exists()) + + def test_invalid_or_missing_marker_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + (app.server_dir / "Paper-26.2-84.jar").write_bytes(b"paper") + + for marker in (None, "../Paper-26.2-84.jar", "Paper-26.3-84.jar"): + if marker is None: + app.last_launched_jar_marker_path.unlink(missing_ok=True) + else: + self.write_marker(app, marker) + with self.subTest(marker=marker): + with self.assertRaises(paperscript.PaperScriptError): + app.plan_server_jar_retention("26.2", keep=2) + + self.assertTrue((app.server_dir / "Paper-26.2-84.jar").is_file()) + + def test_retention_rejects_fewer_than_two_protection_slots(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + (app.server_dir / "Paper-26.2-84.jar").write_bytes(b"paper") + self.write_marker(app, "Paper-26.2-84.jar") + + with self.assertRaises(paperscript.PaperScriptError): + app.plan_server_jar_retention("26.2", keep=1) + + def test_dry_run_does_not_move_or_create_any_jar_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + for build in (9, 10, 11): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes(b"paper") + self.write_marker(app, "Paper-26.2-9.jar") + plan = app.plan_server_jar_retention("26.2", keep=2) + + app.apply_server_jar_retention(plan, dry_run=True) + + self.assertEqual( + sorted(path.name for path in app.server_dir.glob("Paper-*.jar")), + ["Paper-26.2-10.jar", "Paper-26.2-11.jar", "Paper-26.2-9.jar"], + ) + self.assertFalse(app.jar_archive_dir.exists()) + + def test_archive_cap_keeps_the_highest_numeric_builds(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + app.keep_archived_jars = 3 + archive = app.jar_archive_dir / "26.2" + archive.mkdir(parents=True) + for build in (1, 2, 3): + (archive / f"Paper-26.2-{build}.jar").write_bytes(b"old") + for build in (7, 8, 9, 10): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes(b"root") + self.write_marker(app, "Paper-26.2-10.jar") + + plan = app.plan_server_jar_retention("26.2", keep=2) + app.apply_server_jar_retention(plan) + + self.assertEqual( + sorted(path.name for path in archive.iterdir()), + ["Paper-26.2-7.jar", "Paper-26.2-8.jar", "Paper-26.2-9.jar"], + ) + + def test_dry_run_lists_the_exact_archive_files_that_would_be_pruned(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + app.keep_archived_jars = 2 + archive = app.jar_archive_dir / "26.2" + archive.mkdir(parents=True) + for build in (1, 2): + (archive / f"Paper-26.2-{build}.jar").write_bytes(b"old") + for build in (7, 8, 9): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes(b"root") + self.write_marker(app, "Paper-26.2-9.jar") + + plan = app.plan_server_jar_retention("26.2", keep=2) + app.apply_server_jar_retention(plan, dry_run=True) + + self.assertEqual( + [path.name for path in plan.to_prune], + ["Paper-26.2-2.jar", "Paper-26.2-1.jar"], + ) + self.assertTrue( + all( + any(path.name in message and "permanently prune" in message for message in app.logger.messages) + for path in plan.to_prune + ) + ) + self.assertEqual( + sorted(path.name for path in archive.iterdir()), + ["Paper-26.2-1.jar", "Paper-26.2-2.jar"], + ) + + def test_archive_directory_sync_failure_keeps_the_root_copy(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + source = app.server_dir / "Paper-26.2-8.jar" + source.write_bytes(b"root") + + with mock.patch.object( + paperscript, + "fsync_directory", + side_effect=paperscript.PaperScriptError("sync failed"), + ): + with self.assertRaisesRegex(paperscript.PaperScriptError, "sync failed"): + app.archive_server_jar(source, "26.2") + + self.assertTrue(source.is_file()) + + def test_archive_publish_sync_chain_reaches_the_server_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + archive = app.jar_archive_dir / "26.2" + + with mock.patch.object(paperscript, "fsync_directory") as sync_directory: + app.fsync_archive_publish_chain(archive) + + sync_directory.assert_any_call(app.server_dir, strict=True) + + def test_server_mutations_are_locked_per_server(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + app.server_lock_path = app.server_runtime_dir / "locks" / "paper-jars.lock" + + with app.server_mutation_lock(): + with self.assertRaises(paperscript.PaperScriptError): + with app.server_mutation_lock(): + self.fail("A second mutation lock should not be acquired") + + def test_legacy_backup_cleanup_preserves_the_managed_jar_archive(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + legacy_backup = app.server_runtime_dir / "backups" / "old.jar" + archived = app.jar_archive_dir / "26.2" / "Paper-26.2-8.jar" + archived.parent.mkdir(parents=True) + legacy_backup.write_bytes(b"legacy") + archived.write_bytes(b"managed archive") + + removed = app.remove_directory_contents( + app.server_runtime_dir / "backups", + excluded_paths=(app.jar_archive_dir,), + ) + + self.assertEqual(removed, 1) + self.assertFalse(legacy_backup.exists()) + self.assertEqual(archived.read_bytes(), b"managed archive") + + def test_cleanup_aborts_if_the_confirmed_jar_plan_changes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + app.server_lock_path = app.server_runtime_dir / "locks" / "paper-jars.lock" + app.state = {} + app.args = argparse.Namespace( + cleanup_all=False, + cleanup_downloads=False, + cleanup_backups=False, + cleanup_server_jars=True, + cleanup_metadata_cache=False, + cleanup_pycache=False, + cleanup_logs=False, + cleanup_json=False, + cleanup_keep=2, + cleanup_version="26.2", + dry_run=False, + yes=True, + ) + for build in (9, 10, 11): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes( + f"build {build}".encode() + ) + self.write_marker(app, "Paper-26.2-9.jar") + approved = app.plan_server_jar_retention("26.2", keep=2) + changed = paperscript.JarRetentionPlan( + version="26.2", + last_launched=approved.last_launched, + kept=( + app.server_dir / "Paper-26.2-9.jar", + app.server_dir / "Paper-26.2-10.jar", + ), + to_archive=(app.server_dir / "Paper-26.2-11.jar",), + ) + + with mock.patch.object( + app, + "plan_server_jar_retention", + side_effect=(approved, changed), + ), mock.patch.object(app, "apply_server_jar_retention") as apply_retention: + with self.assertRaisesRegex(paperscript.PaperScriptError, "changed"): + app.run_cleanup() + + apply_retention.assert_not_called() + for build in (9, 10, 11): + self.assertTrue((app.server_dir / f"Paper-26.2-{build}.jar").is_file()) + + def test_cleanup_reloads_staged_state_under_the_server_lock(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = self.make_app(directory) + app.server_lock_path = app.server_runtime_dir / "locks" / "paper-jars.lock" + app.state_path = app.server_runtime_dir / "state.json" + app.args = argparse.Namespace( + cleanup_all=False, + cleanup_downloads=False, + cleanup_backups=False, + cleanup_server_jars=True, + cleanup_metadata_cache=False, + cleanup_pycache=False, + cleanup_logs=False, + cleanup_json=False, + cleanup_keep=2, + cleanup_version="26.2", + dry_run=False, + yes=False, + ) + for build in (9, 10, 11): + (app.server_dir / f"Paper-26.2-{build}.jar").write_bytes( + f"build {build}".encode() + ) + self.write_marker(app, "Paper-26.2-9.jar") + app.state = { + "staged_jar": "Paper-26.2-11.jar", + "staged_version": "26.2", + "server_dir": str(app.server_dir), + } + app._save_json(app.state_path, app.state) + + def confirm_after_concurrent_state_change(*args, **kwargs): + app._save_json( + app.state_path, + { + "staged_jar": "Paper-26.2-11.jar", + "staged_version": "26.2", + "staged_sha256": "0" * 64, + "server_dir": str(app.server_dir), + }, + ) + return True + + with mock.patch.object( + paperscript, + "prompt_yes_no", + side_effect=confirm_after_concurrent_state_change, + ), mock.patch.object(app, "apply_server_jar_retention") as apply_retention: + with self.assertRaisesRegex(paperscript.PaperScriptError, "no longer matches"): + app.run_cleanup() + + apply_retention.assert_not_called() + self.assertEqual(app.state["staged_jar"], "Paper-26.2-11.jar") + self.assertEqual(app.state["staged_sha256"], "0" * 64) + for build in (9, 10, 11): + self.assertTrue((app.server_dir / f"Paper-26.2-{build}.jar").is_file()) + + +class NonDisruptiveStagingTests(unittest.TestCase): + def make_app( + self, + directory: str, + content: bytes, + marker: str | None, + ) -> tuple[paperscript.PaperScriptApp, paperscript.BuildInfo]: + app = object.__new__(paperscript.PaperScriptApp) + app.server_dir = Path(directory) + app.server_runtime_dir = app.server_dir / "paperscript" + app.last_launched_jar_marker_path = app.server_runtime_dir / "last-launched-jar.txt" + app.jar_archive_dir = app.server_runtime_dir / "backups" / "jars" + app.server_lock_path = app.server_runtime_dir / "locks" / "paper-jars.lock" + app.keep_server_jars = 2 + app.keep_archived_jars = 5 + app.reconcile_server_jars_after_stage = True + app.allow_same_version_build_upgrade = True + app.confirm_before_force_download = True + app.confirm_before_downgrade = True + app.args = argparse.Namespace(dry_run=False, force=False, yes=True) + app.state = {} + app.state_path = app.server_runtime_dir / "state.json" + app.logger = RecordingLogger() + if marker is not None: + app.server_runtime_dir.mkdir(parents=True) + app.last_launched_jar_marker_path.write_text(marker + "\n", encoding="utf-8") + + digest = paperscript.hashlib.sha256(content).hexdigest() + build = paperscript.BuildInfo( + version="26.2", + build_id=11, + channel="STABLE", + download_name="paper-26.2-11.jar", + download_url="https://example.invalid/paper-26.2-11.jar", + sha256=digest, + size=len(content), + created_at="2026-08-20T00:00:00Z", + ) + + class FakeAPI: + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=digest, + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + app.api = FakeAPI() + # Existing staging tests isolate post-authorization behavior. Dedicated tests below + # bind the production revalidation method and exercise the fresh API boundary. + app.revalidate_build_for_staging = lambda selected, **kwargs: selected + return app, build + + def bind_production_revalidation(self, app: paperscript.PaperScriptApp) -> None: + app.revalidate_build_for_staging = ( + paperscript.PaperScriptApp.revalidate_build_for_staging.__get__( + app, + paperscript.PaperScriptApp, + ) + ) + + def test_staging_replaces_cached_metadata_with_fresh_exact_build(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"freshly authorized paper jar") + app, cached = self.make_app(directory, content, marker=None) + cached.download_url = "https://attacker.invalid/cache-selected.jar" + cached.sha256 = "f" * 64 + cached.size = 1 + fresh = paperscript.BuildInfo( + version=cached.version, + build_id=cached.build_id, + channel=cached.channel, + download_name="paper-26.2-11.jar", + download_url="https://downloads.example.invalid/authentic.jar", + sha256=paperscript.hashlib.sha256(content).hexdigest(), + size=len(content), + created_at="2026-08-30T00:00:00Z", + ) + + class FreshAPI: + lookup: tuple[str, int, bool] | None = None + downloaded: paperscript.BuildInfo | None = None + + def get_build_by_id( + self, + version: str, + build_id: int, + *, + use_cache: bool = True, + ) -> paperscript.BuildInfo: + self.lookup = (version, build_id, use_cache) + return fresh + + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + self.downloaded = selected + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=fresh.sha256 or "", + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + api = FreshAPI() + app.api = api + self.bind_production_revalidation(app) + + app.stage_build( + cached, + selection_policy=paperscript.STAGE_SELECTION_EXACT, + ) + + self.assertEqual(api.lookup, ("26.2", 11, False)) + self.assertIs(api.downloaded, fresh) + self.assertEqual( + (app.server_dir / "Paper-26.2-11.jar").read_bytes(), + content, + ) + + def test_latest_channel_staging_supersedes_stale_cached_build_selection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"fresh latest channel build") + app, cached = self.make_app(directory, content, marker=None) + fresh = paperscript.BuildInfo( + version="26.2", + build_id=12, + channel="STABLE", + download_name="paper-26.2-12.jar", + download_url="https://downloads.example.invalid/paper-26.2-12.jar", + sha256=paperscript.hashlib.sha256(content).hexdigest(), + size=len(content), + created_at="2026-08-30T01:00:00Z", + ) + + class LatestAPI: + request: tuple[str, str, bool] | None = None + + def get_latest_build( + self, + version: str, + channel: str, + *, + use_cache: bool = True, + ) -> paperscript.BuildInfo: + self.request = (version, channel, use_cache) + return fresh + + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=fresh.sha256 or "", + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + api = LatestAPI() + app.api = api + self.bind_production_revalidation(app) + + app.stage_build( + cached, + selection_policy=paperscript.STAGE_SELECTION_LATEST_CHANNEL, + required_channel="STABLE", + ) + + self.assertEqual(api.request, ("26.2", "STABLE", False)) + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual( + (app.server_dir / "Paper-26.2-12.jar").read_bytes(), + content, + ) + + def test_latest_overall_staging_supersedes_stale_cached_family_selection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"fresh latest overall family") + app, cached = self.make_app(directory, content, marker=None) + fresh = paperscript.BuildInfo( + version="26.3", + build_id=2, + channel="STABLE", + download_name="paper-26.3-2.jar", + download_url="https://downloads.example.invalid/paper-26.3-2.jar", + sha256=paperscript.hashlib.sha256(content).hexdigest(), + size=len(content), + created_at="2026-08-30T02:00:00Z", + ) + + class LatestOverallAPI: + def get_project_versions(self, *, use_cache: bool = True) -> list[dict[str, str]]: + self.versions_use_cache = use_cache + return [{"id": "26.3"}, {"id": "26.2"}] + + def get_latest_build( + self, + version: str, + channel: str, + *, + use_cache: bool = True, + ) -> paperscript.BuildInfo | None: + self.build_request = (version, channel, use_cache) + return fresh if version == "26.3" and channel == "STABLE" else None + + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=fresh.sha256 or "", + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + api = LatestOverallAPI() + app.api = api + self.bind_production_revalidation(app) + + app.stage_build( + cached, + selection_policy=paperscript.STAGE_SELECTION_LATEST_OVERALL, + required_channel="STABLE", + ) + + self.assertFalse(api.versions_use_cache) + self.assertEqual(api.build_request, ("26.3", "STABLE", False)) + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual( + (app.server_dir / "Paper-26.3-2.jar").read_bytes(), + content, + ) + + def test_staging_refuses_build_missing_from_fresh_response_before_download(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, selected = self.make_app( + directory, + executable_jar_bytes(b"must not download"), + marker=None, + ) + + class MissingBuildAPI: + download_called = False + + def get_build_by_id( + self, + version: str, + build_id: int, + *, + use_cache: bool = True, + ) -> None: + self.use_cache = use_cache + return None + + def download_file(self, *args: object, **kwargs: object) -> None: + self.download_called = True + raise AssertionError("download must not start") + + api = MissingBuildAPI() + app.api = api + self.bind_production_revalidation(app) + + with self.assertRaisesRegex(paperscript.PaperScriptError, "fresh Paper API response"): + app.stage_build( + selected, + selection_policy=paperscript.STAGE_SELECTION_EXACT, + ) + + self.assertFalse(api.use_cache) + self.assertFalse(api.download_called) + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + + def test_staging_refuses_when_fresh_api_is_unavailable_even_with_cached_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, selected = self.make_app( + directory, + executable_jar_bytes(b"must not download"), + marker=None, + ) + + class OfflineAPI: + download_called = False + + def get_build_by_id(self, *args: object, **kwargs: object) -> None: + raise paperscript.PaperScriptError("simulated Paper API outage") + + def download_file(self, *args: object, **kwargs: object) -> None: + self.download_called = True + raise AssertionError("download must not start") + + api = OfflineAPI() + app.api = api + self.bind_production_revalidation(app) + + with self.assertRaisesRegex(paperscript.PaperScriptError, "simulated Paper API outage"): + app.stage_build( + selected, + selection_policy=paperscript.STAGE_SELECTION_EXACT, + ) + + self.assertFalse(api.download_called) + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + + def test_staging_refuses_fresh_channel_mismatch_before_download(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, selected = self.make_app( + directory, + executable_jar_bytes(b"must not download"), + marker=None, + ) + selected.channel = "STABLE" + fresh = build_info(version="26.2", build_id=11, channel="BETA") + + class ChangedChannelAPI: + def get_latest_build(self, *args: object, **kwargs: object) -> paperscript.BuildInfo: + return fresh + + def download_file(self, *args: object, **kwargs: object) -> None: + raise AssertionError("download must not start") + + app.api = ChangedChannelAPI() + self.bind_production_revalidation(app) + + with self.assertRaisesRegex(paperscript.PaperScriptError, "while STABLE was required"): + app.stage_build( + selected, + selection_policy=paperscript.STAGE_SELECTION_LATEST_CHANNEL, + required_channel="STABLE", + ) + + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + + def test_staging_refuses_non_https_fresh_download_url(self) -> None: + for unsafe_url in ( + "http://downloads.example.invalid/paper.jar", + "file:///tmp/paper.jar", + "https://user:password@downloads.example.invalid/paper.jar", + "https://downloads.example.invalid/paper.jar\x1b]52;c;secret\x07", + "https://[malformed/paper.jar", + ): + with self.subTest(url=unsafe_url), tempfile.TemporaryDirectory() as directory: + app, selected = self.make_app( + directory, + executable_jar_bytes(b"must not download"), + marker=None, + ) + fresh = build_info(version="26.2", build_id=11) + fresh.download_url = unsafe_url + + class UnsafeURLAPI: + def get_build_by_id(self, *args: object, **kwargs: object) -> paperscript.BuildInfo: + return fresh + + def download_file(self, *args: object, **kwargs: object) -> None: + raise AssertionError("download must not start") + + app.api = UnsafeURLAPI() + self.bind_production_revalidation(app) + + with self.assertRaisesRegex(paperscript.PaperScriptError, "safe HTTPS"): + app.stage_build( + selected, + selection_policy=paperscript.STAGE_SELECTION_EXACT, + ) + + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + + def test_stages_beside_active_jar_without_changing_it(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"verified new paper jar"), + "Paper-26.2-9.jar", + ) + active = app.server_dir / "Paper-26.2-9.jar" + old_staged = app.server_dir / "Paper-26.2-10.jar" + active.write_bytes(b"active paper jar") + old_staged.write_bytes(b"older staged jar") + active_before = active.stat() + active_hash = paperscript.sha256_file(active) + + app.stage_build(build) + + self.assertEqual(paperscript.sha256_file(active), active_hash) + active_after = active.stat() + self.assertEqual(active_before.st_ino, active_after.st_ino) + self.assertEqual(active_before.st_mtime_ns, active_after.st_mtime_ns) + staged = app.server_dir / "Paper-26.2-11.jar" + self.assertTrue(staged.is_file()) + self.assertEqual(staged.stat().st_mode & 0o777, 0o644) + self.assertFalse(old_staged.exists()) + self.assertTrue( + (app.jar_archive_dir / "26.2" / old_staged.name).is_file() + ) + self.assertEqual(app.state["staged_jar"], "Paper-26.2-11.jar") + self.assertTrue( + any("did not stop, start, restart, or signal" in message for message in app.logger.messages) + ) + + def test_staging_never_invokes_process_or_tmux_commands(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"verified new paper jar"), + "Paper-26.2-9.jar", + ) + (app.server_dir / "Paper-26.2-9.jar").write_bytes(b"active") + + with mock.patch.object( + paperscript, + "run_command", + side_effect=AssertionError("staging attempted an external process command"), + ): + app.stage_build(build) + + self.assertTrue((app.server_dir / "Paper-26.2-11.jar").is_file()) + + def test_missing_launcher_marker_stages_but_defers_root_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"verified new paper jar"), + marker=None, + ) + old_names = ["Paper-26.2-9.jar", "Paper-26.2-10.jar"] + for name in old_names: + (app.server_dir / name).write_bytes(name.encode()) + + app.stage_build(build) + + for name in old_names + ["Paper-26.2-11.jar"]: + self.assertTrue((app.server_dir / name).is_file()) + self.assertFalse(app.jar_archive_dir.exists()) + self.assertTrue( + any("retention deferred" in message for message in app.logger.messages) + ) + + def test_target_appearing_during_download_is_never_overwritten(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"verified new paper jar") + app, build = self.make_app(directory, content, marker=None) + target = app.server_dir / "Paper-26.2-11.jar" + + class RacingAPI: + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(content) + target.write_bytes(b"concurrent sentinel") + return paperscript.DownloadVerification( + sha256=selected.sha256 or "", + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + app.api = RacingAPI() + + with self.assertRaises(paperscript.PaperScriptError): + app.stage_build(build) + + self.assertEqual(target.read_bytes(), b"concurrent sentinel") + + def test_failed_verification_removes_temporary_downloads(self) -> None: + for failure in ("missing_sha", "missing_size", "size", "checksum"): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"downloaded paper bytes") + app, build = self.make_app( + directory, + content, + "Paper-26.2-9.jar", + ) + active = app.server_dir / "Paper-26.2-9.jar" + active.write_bytes(b"active") + if failure == "missing_sha": + build.sha256 = None + elif failure == "missing_size": + build.size = None + else: + expected_sha = build.sha256 or "" + + class InvalidVerificationAPI: + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=("f" * 64 if failure == "checksum" else expected_sha), + bytes_written=(len(content) + 1 if failure == "size" else len(content)), + elapsed_seconds=0.01, + ) + + app.api = InvalidVerificationAPI() + + with self.assertRaises(paperscript.PaperScriptError): + app.stage_build(build) + + self.assertEqual(active.read_bytes(), b"active") + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + + def test_preflight_refuses_insufficient_space_before_download(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"space preflight"), + "Paper-26.2-9.jar", + ) + (app.server_dir / "Paper-26.2-9.jar").write_bytes(b"active") + available = (build.size or 0) + paperscript.STAGING_FREE_SPACE_RESERVE_BYTES - 1 + + with mock.patch.object( + paperscript.shutil, + "disk_usage", + return_value=mock.Mock(free=available), + ), mock.patch.object(app.api, "download_file") as download: + with self.assertRaisesRegex(paperscript.PaperScriptError, "No download was started"): + app.stage_build(build) + + download.assert_not_called() + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + + def test_preflight_refuses_missing_server_root_write_access_before_download(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"permission preflight"), + "Paper-26.2-9.jar", + ) + (app.server_dir / "Paper-26.2-9.jar").write_bytes(b"active") + + with mock.patch.object( + paperscript.tempfile, + "mkstemp", + side_effect=PermissionError("simulated read-only server root"), + ), mock.patch.object(app.api, "download_file") as download: + with self.assertRaisesRegex(paperscript.PaperScriptError, "Staging preflight"): + app.stage_build(build) + + download.assert_not_called() + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + + def test_preflight_refuses_unsupported_durable_directory_sync_before_download(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"directory sync preflight"), + "Paper-26.2-9.jar", + ) + (app.server_dir / "Paper-26.2-9.jar").write_bytes(b"active") + + with mock.patch.object( + paperscript, + "fsync_directory", + side_effect=paperscript.PaperScriptError("simulated unsupported directory sync"), + ), mock.patch.object(app.api, "download_file") as download: + with self.assertRaisesRegex(paperscript.PaperScriptError, "unsupported directory sync"): + app.stage_build(build) + + download.assert_not_called() + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + + def test_non_executable_or_corrupt_archives_are_never_published(self) -> None: + archive_without_manifest = io.BytesIO() + with zipfile.ZipFile(archive_without_manifest, "w") as archive: + archive.writestr("example/Main.class", b"\xca\xfe\xba\xbe") + + archive_with_invalid_class = io.BytesIO() + with zipfile.ZipFile(archive_with_invalid_class, "w") as archive: + archive.writestr( + "META-INF/MANIFEST.MF", + "Manifest-Version: 1.0\r\nMain-Class: example.Main\r\n\r\n", + ) + archive.writestr("example/Main.class", b"not-a-class") + + valid_archive = executable_jar_bytes(b"truncate me") + invalid_contents = ( + b"not a zip archive", + archive_without_manifest.getvalue(), + archive_with_invalid_class.getvalue(), + valid_archive[:-12], + ) + for content in invalid_contents: + with self.subTest(size=len(content)), tempfile.TemporaryDirectory() as directory: + app, build = self.make_app(directory, content, "Paper-26.2-9.jar") + active = app.server_dir / "Paper-26.2-9.jar" + active.write_bytes(b"active") + + with self.assertRaises(paperscript.PaperScriptError): + app.stage_build(build) + + self.assertEqual(active.read_bytes(), b"active") + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + + def test_replaced_private_staging_path_is_rejected_without_publishing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"replacement race") + app, build = self.make_app(directory, content, "Paper-26.2-9.jar") + active = app.server_dir / "Paper-26.2-9.jar" + active.write_bytes(b"active") + + class ReplacingAPI: + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.unlink() + destination.write_bytes(content) + return paperscript.DownloadVerification( + sha256=selected.sha256 or "", + bytes_written=len(content), + elapsed_seconds=0.01, + ) + + app.api = ReplacingAPI() + with self.assertRaisesRegex(paperscript.PaperScriptError, "private regular file"): + app.stage_build(build) + + self.assertEqual(active.read_bytes(), b"active") + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + changed_paths = list(app.server_dir.glob(".*.part")) + self.assertEqual(len(changed_paths), 1) + changed_paths[0].unlink() + + def test_final_directory_sync_failure_never_exposes_partial_bytes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"durable publish") + app, build = self.make_app(directory, content, "Paper-26.2-9.jar") + active = app.server_dir / "Paper-26.2-9.jar" + active.write_bytes(b"active") + + with mock.patch.object( + paperscript, + "fsync_directory", + side_effect=[None, paperscript.PaperScriptError("simulated final sync failure")], + ): + with self.assertRaisesRegex(paperscript.PaperScriptError, "final sync failure"): + app.stage_build(build) + + target = app.server_dir / "Paper-26.2-11.jar" + self.assertEqual(target.read_bytes(), content) + self.assertEqual(active.read_bytes(), b"active") + self.assertEqual(app.state, {}) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + + def test_partial_download_exception_is_normalized_and_cleans_temp_state(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"unused"), + "Paper-26.2-9.jar", + ) + active = app.server_dir / "Paper-26.2-9.jar" + active.write_bytes(b"active") + + class PartialWriteAPI: + def download_file( + self, + selected: paperscript.BuildInfo, + destination: Path, + destination_descriptor: int | None = None, + ) -> paperscript.DownloadVerification: + destination.write_bytes(b"partial") + raise OSError("simulated disk or stream failure") + + app.api = PartialWriteAPI() + + with self.assertRaisesRegex(paperscript.PaperScriptError, "Download I/O failed"): + app.stage_build(build) + + self.assertEqual(active.read_bytes(), b"active") + self.assertFalse((app.server_dir / "Paper-26.2-11.jar").exists()) + self.assertEqual(list(app.server_dir.glob(".*.part")), []) + self.assertEqual(app.state, {}) + + def test_same_family_build_downgrade_is_rejected_even_with_force(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app, build = self.make_app( + directory, + executable_jar_bytes(b"older build"), + "Paper-26.2-9.jar", + ) + (app.server_dir / "Paper-26.2-9.jar").write_bytes(b"active") + build.build_id = 8 + build.download_name = "paper-26.2-8.jar" + build.download_url = "https://example.invalid/paper-26.2-8.jar" + app.args.force = True + + with self.assertRaisesRegex(paperscript.PaperScriptError, "could not become the next launch"): + app.stage_build(build) + + self.assertFalse((app.server_dir / "Paper-26.2-8.jar").exists()) + self.assertEqual( + app.last_launched_jar().path.name, + "Paper-26.2-9.jar", + ) + + def test_force_redownload_verifies_without_replacing_existing_target(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = executable_jar_bytes(b"verified existing paper jar") + app, build = self.make_app(directory, content, "Paper-26.2-11.jar") + target = app.server_dir / "Paper-26.2-11.jar" + target.write_bytes(content) + before = target.stat() + app.args.force = True + + app.stage_build(build) + + after = target.stat() + self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns)) + self.assertEqual(target.read_bytes(), content) + self.assertTrue( + any("Re-downloaded and verified" in message for message in app.logger.messages) + ) + + def test_existing_target_with_matching_metadata_but_invalid_jar_is_never_replaced(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = b"not an executable jar" + app, build = self.make_app(directory, content, "Paper-26.2-11.jar") + target = app.server_dir / "Paper-26.2-11.jar" + target.write_bytes(content) + before = target.stat() + app.args.force = True + + with mock.patch.object(app.api, "download_file") as download: + with self.assertRaisesRegex(paperscript.PaperScriptError, "failed exact Paper artifact"): + app.stage_build(build) + + download.assert_not_called() + after = target.stat() + self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns)) + self.assertEqual(target.read_bytes(), content) + + +class CleanupSelectionCompatibilityTests(unittest.TestCase): + def make_app(self, **overrides: object) -> paperscript.PaperScriptApp: + values = { + "cleanup_all": False, + "cleanup_downloads": False, + "cleanup_backups": False, + "cleanup_server_jars": False, + "cleanup_metadata_cache": False, + "cleanup_keep": None, + "cleanup_version": None, + "cleanup_pycache": False, + "cleanup_logs": False, + "cleanup_json": False, + } + values.update(overrides) + app = object.__new__(paperscript.PaperScriptApp) + app.args = argparse.Namespace(**values) + return app + + def test_existing_cleanup_keep_still_selects_backups(self) -> None: + selection = self.make_app(cleanup_keep=2).cleanup_selection() + self.assertTrue(selection["backups"]) + self.assertFalse(selection["server_jars"]) + + def test_server_jars_is_explicit_and_not_part_of_all(self) -> None: + server_selection = self.make_app( + cleanup_server_jars=True, + cleanup_keep=2, + ).cleanup_selection() + self.assertTrue(server_selection["server_jars"]) + self.assertFalse(server_selection["backups"]) + + all_selection = self.make_app(cleanup_all=True).cleanup_selection() + self.assertFalse(all_selection["server_jars"]) + + def test_backup_and_server_jar_targets_cannot_be_combined(self) -> None: + app = self.make_app( + cleanup_backups=True, + cleanup_server_jars=True, + ) + with self.assertRaises(paperscript.PaperScriptError): + app.cleanup_selection() + + def test_cleanup_version_requires_server_jars(self) -> None: + app = self.make_app(cleanup_version="26.2") + with self.assertRaises(paperscript.PaperScriptError): + app.cleanup_selection() class UpdateSelectionTests(unittest.TestCase): @@ -158,7 +2273,13 @@ def make_app(self, current_build: int, latest_build: int, allow_upgrade: bool): current_build, ) target = build_info(build_id=latest_build) - app.latest_stable_version = lambda: ("26.2", target) + app.latest_stable_version = lambda **kwargs: ("26.2", target) + app.last_launched_jar = lambda: (_ for _ in ()).throw( + paperscript.PaperScriptError("marker unavailable") + ) + app.launcher_jar_selection = lambda: (_ for _ in ()).throw( + paperscript.PaperScriptError("marker unavailable") + ) app.allow_same_version_build_upgrade = allow_upgrade app.args = argparse.Namespace(force=False) app.logger = RecordingLogger() @@ -166,20 +2287,143 @@ def make_app(self, current_build: int, latest_build: int, allow_upgrade: bool): def test_same_version_newer_stable_build_is_selected(self) -> None: app, expected = self.make_app(83, 84, allow_upgrade=True) - self.assertIs(app.choose_target_for_update(), expected) + selection = app.choose_target_for_update() + self.assertIsNotNone(selection) + self.assertIs(selection.build, expected) + self.assertEqual( + selection.staging_policy, + paperscript.STAGE_SELECTION_LATEST_CHANNEL, + ) def test_same_version_upgrade_respects_disabled_config(self) -> None: app, _ = self.make_app(83, 84, allow_upgrade=False) self.assertIsNone(app.choose_target_for_update()) + def test_empty_server_update_requires_fresh_latest_overall_policy(self) -> None: + app, expected = self.make_app(0, 84, allow_upgrade=True) + app.find_current_jar = lambda: None + + selection = app.choose_target_for_update() + + self.assertIsNotNone(selection) + self.assertIs(selection.build, expected) + self.assertEqual( + selection.staging_policy, + paperscript.STAGE_SELECTION_LATEST_OVERALL, + ) + + def test_run_update_bypasses_cache_and_passes_policy_to_shared_boundary(self) -> None: + app = object.__new__(paperscript.PaperScriptApp) + target = build_info() + selection = paperscript.UpdateSelection( + target, + paperscript.STAGE_SELECTION_LATEST_OVERALL, + ) + app.describe_server_context = mock.Mock() + app.log_api_activity = mock.Mock() + app.choose_target_for_update = mock.Mock(return_value=selection) + app.stage_build = mock.Mock() + app.check_latest_channel_only = "STABLE" + app.logger = RecordingLogger() + + app.run_update() + + app.choose_target_for_update.assert_called_once_with(use_cache=False) + app.stage_build.assert_called_once_with( + target, + force_version_prompt=False, + selection_policy=paperscript.STAGE_SELECTION_LATEST_OVERALL, + required_channel="STABLE", + ) + + def test_update_follows_launcher_marker_instead_of_newer_staged_family(self) -> None: + app = object.__new__(paperscript.PaperScriptApp) + app.find_current_jar = lambda: paperscript.JarInfo( + Path("Paper-26.3-2.jar"), + "26.3", + 2, + ) + app.last_launched_jar = lambda: paperscript.JarInfo( + Path("Paper-26.2-84.jar"), + "26.2", + 84, + ) + overall = build_info(version="26.3", build_id=2) + expected = build_info(version="26.2", build_id=85) + app.latest_stable_version = lambda **kwargs: ("26.3", overall) + + class FakeAPI: + def get_latest_build(self, version: str, channel: str, *, use_cache: bool = True): + self.request = (version, channel, use_cache) + return expected + + app.api = FakeAPI() + app.check_latest_channel_only = "STABLE" + app.allow_same_version_build_upgrade = True + app.args = argparse.Namespace(force=False, dry_run=False) + app.logger = RecordingLogger() + + selected = app.choose_target_for_update() + + self.assertIsNotNone(selected) + self.assertIs(selected.build, expected) + self.assertEqual( + selected.staging_policy, + paperscript.STAGE_SELECTION_LATEST_CHANNEL, + ) + self.assertEqual(app.api.request, ("26.2", "STABLE", True)) + + def test_legacy_launcher_marker_keeps_update_on_its_version_family(self) -> None: + with tempfile.TemporaryDirectory() as directory: + app = object.__new__(paperscript.PaperScriptApp) + app.server_dir = Path(directory) + app.server_runtime_dir = app.server_dir / "paperscript" + app.last_launched_jar_marker_path = ( + app.server_runtime_dir / paperscript.LAST_LAUNCHED_JAR_MARKER + ) + app.server_runtime_dir.mkdir() + legacy = app.server_dir / "paper-26.2.jar" + legacy.write_bytes(b"legacy") + app.last_launched_jar_marker_path.write_text( + legacy.name + "\n", encoding="utf-8" + ) + app.state = {} + app.find_current_jar = lambda: paperscript.JarInfo( + Path("Paper-26.3-2.jar"), "26.3", 2 + ) + overall = build_info(version="26.3", build_id=2) + expected = build_info(version="26.2", build_id=85) + app.latest_stable_version = lambda **kwargs: ("26.3", overall) + + class FakeAPI: + def get_latest_build(self, version: str, channel: str, *, use_cache: bool = True): + self.request = (version, channel, use_cache) + return expected + + app.api = FakeAPI() + app.check_latest_channel_only = "STABLE" + app.allow_same_version_build_upgrade = True + app.args = argparse.Namespace(force=False, dry_run=False) + app.logger = RecordingLogger() + + selected = app.choose_target_for_update() + + self.assertIsNotNone(selected) + self.assertIs(selected.build, expected) + self.assertEqual( + selected.staging_policy, + paperscript.STAGE_SELECTION_LATEST_CHANNEL, + ) + self.assertEqual(app.api.request, ("26.2", "STABLE", True)) + class PreviewSelectionTests(unittest.TestCase): def test_preview_prefers_beta_then_alpha_on_newer_versions(self) -> None: class FakeAPI: - def get_project_versions(self): + def get_project_versions(self, *, use_cache: bool = True): return [{"id": "26.3"}, {"id": "26.2"}] - def get_latest_build(self, version: str, channel: str): + def get_latest_build(self, version: str, channel: str, *, use_cache: bool = True): if version == "26.3" and channel == "BETA": return build_info(version="26.3", build_id=7, channel="BETA") if version == "26.3" and channel == "ALPHA":