feat(sentinel): alert on repeated suspicious movement #1

Merged
dmg merged 1 commits from ticket-496-alert-operators-to-repeated-suspicious-movement into main 2026-10-29 12:58:51 +00:00
54 changed files with 3272 additions and 2 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout public source without a credential
env:
SOURCE_SHA: ${{ github.sha }}
GIT_TERMINAL_PROMPT: '0'
run: |
test "${#SOURCE_SHA}" -eq 40
printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$'
git init .
git remote add origin https://git.garvis.dev/dmg/purpur-sentinel.git
git config --local credential.helper ''
git config --local http.extraHeader ''
git config --local http.followRedirects false
git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch origin "$SOURCE_SHA"
git checkout --detach "$SOURCE_SHA"
- name: Set up Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- name: Validate conventional commits
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: |
npx -y -p @commitlint/cli -p @commitlint/config-conventional \
commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --extends @commitlint/config-conventional
- name: Build and test
run: ./gradlew clean check jar
- name: Upload development artifact
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-sentinel-${{ github.sha }}
path: build/libs/purpur-sentinel-*.jar
if-no-files-found: error
+122
View File
@@ -0,0 +1,122 @@
name: Release
on:
push:
branches: [main]
concurrency:
group: sentinel-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout public source without a credential
env:
SOURCE_SHA: ${{ github.sha }}
GIT_TERMINAL_PROMPT: '0'
run: |
test "${#SOURCE_SHA}" -eq 40
printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$'
git init .
git remote add origin https://git.garvis.dev/dmg/purpur-sentinel.git
git config --local credential.helper ''
git config --local http.extraHeader ''
git config --local http.followRedirects false
git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch origin "$SOURCE_SHA"
git checkout -B main "$SOURCE_SHA"
- name: Set up Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- name: Build and test before release
run: ./gradlew clean check jar
- name: Capture previous tag
id: previous
run: |
previous=$(git describe --tags --abbrev=0 2>/dev/null || true)
printf 'tag=%s\n' "$previous" >> "$GITHUB_OUTPUT"
- name: Compute and push semantic release tag
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
REPOSITORY: ${{ github.repository }}
LC_ALL: C
run: |
set +x
test "$REPOSITORY" = dmg/purpur-sentinel
test -n "$RELEASE_CREDENTIAL" || { printf '%s\n' 'Missing RELEASE_TOKEN.' >&2; exit 1; }
# Prevent semantic-release from embedding any recognized token in a Git URL/argv.
unset GIT_CREDENTIALS GH_TOKEN GITHUB_TOKEN GL_TOKEN GITLAB_TOKEN
unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH
unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET
unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF
# semantic-release forces GIT_ASKPASS=echo. Use a scoped, memory-only helper instead.
export GIT_TERMINAL_PROMPT=0
git config --local credential.helper ''
git config --local --add credential.helper "$GITHUB_WORKSPACE/scripts/git-credential.sh"
git config --local credential.useHttpPath true
git config --local http.followRedirects false
npx -y \
-p semantic-release@24.2.9 \
-p @semantic-release/commit-analyzer@13.0.1 \
-p @semantic-release/release-notes-generator@14.1.0 \
semantic-release --branches main \
--repository-url https://git.garvis.dev/dmg/purpur-sentinel.git \
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
- name: Resolve newly created exact-source release
id: release
env:
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
EXPECTED_COMMIT: ${{ github.sha }}
run: |
current=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [ -n "$current" ] && [ "$current" != "$PREVIOUS_TAG" ]; then
printf '%s' "$current" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
test "$(git rev-parse "${current}^{commit}")" = "$EXPECTED_COMMIT"
printf 'created=true\nversion=%s\n' "${current#v}" >> "$GITHUB_OUTPUT"
else
printf 'created=false\n' >> "$GITHUB_OUTPUT"
fi
- name: Verify versioned plugin and release tooling
if: steps.release.outputs.created == 'true'
env:
VERSION: ${{ steps.release.outputs.version }}
run: ./gradlew clean check jar -PreleaseVersion="$VERSION"
- name: Upload release workflow artifact
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-sentinel-${{ steps.release.outputs.version }}
path: build/libs/purpur-sentinel-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Publish verified JAR through a draft release
if: steps.release.outputs.created == 'true'
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
RELEASE_COMMIT: ${{ github.sha }}
VERSION: ${{ steps.release.outputs.version }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: |
set +x
unset GIT_CREDENTIALS GH_TOKEN GITHUB_TOKEN GL_TOKEN GITLAB_TOKEN
unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH
unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET
unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
else
RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' HEAD)
fi
export RELEASE_NOTES
node build/release-tools/publish-release.js
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
node_modules/
.idea/
*.iml
*.log
.DS_Store
+9
View File
@@ -0,0 +1,9 @@
# Purpur Sentinel
Canonical knowledge and stories live in the sibling `somc-okf/` checkout, not this public source repository. Read `../AGENTS.md` when available and the wiki's `index.md`, `processes/index.md`, `processes/development.md`, `runbooks/development-cycle.md`, `projects/purpur-sentinel/index.md`, `engineering.md`, and OpenProject ticket 496 before work.
Standalone checkout entrypoint: https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md . If unavailable, request access; do not create a substitute knowledge bundle or copy private wiki content here.
Obtain explicit story-change approval before implementation and operational approval before infrastructure, tooling, secret, or deployment changes. Use vertical-slice TDD and verify `./gradlew clean check jar`. Java 25, Gradle 9.6.1, Purpur API `26.3.build.2651-stable`; do not silently upgrade. Keep movement processing bounded and blocking I/O off movement handlers, scheduler ticks, and autocomplete. Never persist or export player evidence.
Publishing credentials and release-triggering merges require separate readiness verification. Never put secret bytes in argv, URLs, logs, or Git. No deployment or GitOps push follows implicitly from source work. Preserve unrelated changes and use conventional commits.
+14 -2
View File
@@ -1,3 +1,15 @@
# purpur-sentinel
# Purpur Sentinel
Passive, explainable movement-pattern observation for Purpur
Passive, explainable movement-pattern observation for Purpur servers.
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement. It does not cancel movement, punish players, persist player evidence, or export telemetry.
## Development
Requires Java 25 and Node.js 20.
```sh
./gradlew clean check jar
```
The output JAR is written to `build/libs/`. Production installation and deployment are separately gated operations.
+115
View File
@@ -0,0 +1,115 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
group = "games.dmg"
version = providers.gradleProperty("releaseVersion")
.orElse("0.1.0-SNAPSHOT")
.get()
repositories {
mavenCentral()
maven("https://repo.purpurmc.org/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
val purpurApi = "org.purpurmc.purpur:purpur-api:26.3.build.2651-stable"
val nativeRuntimeName = "purpur-26.3-2651.jar"
val nativeRuntimeUrl = "https://api.purpurmc.org/v2/purpur/26.3/2651/download"
dependencies {
compileOnly(purpurApi)
testImplementation(purpurApi)
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
testImplementation("org.yaml:snakeyaml:2.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
val nativeRuntime = layout.buildDirectory.dir("native-runtime/26.3-2651")
val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
val launcher = nativeRuntime.map { it.file(nativeRuntimeName) }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI(nativeRuntimeUrl).toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareNativeRuntime = tasks.register<JavaExec>("prepareNativeRuntime") {
dependsOn(downloadNativeRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(nativeRuntime.map { it.file(nativeRuntimeName) })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(nativeRuntime)
outputs.dir(nativeRuntime.map { it.dir("versions") })
outputs.dir(nativeRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(nativeRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareNativeRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
description = "Tests packaged Sentinel behavior against the declared native runtime"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(nativeRuntime)
}
val installReleaseTools = tasks.register<Exec>("installReleaseTools") {
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
}
val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
dependsOn(installReleaseTools)
commandLine("npm", "test")
}
tasks.check { dependsOn(nativeCompatibilityTest, releaseToolCheck) }
val pluginVersion = version
tasks.processResources {
inputs.property("pluginVersion", pluginVersion)
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
}
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+50
View File
@@ -0,0 +1,50 @@
{
"name": "purpur-sentinel-release-tools",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "purpur-sentinel-release-tools",
"version": "0.0.0",
"devDependencies": {
"@types/node": "20.19.9",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "20.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "purpur-sentinel-release-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js"
},
"devDependencies": {
"@types/node": "20.19.9",
"typescript": "5.9.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# Git consumes this protocol over a pipe; never run interactively with a real credential.
# semantic-release forces GIT_ASKPASS=echo, but still honors credential helpers.
set +x
[ "${1-}" = get ] || exit 0
protocol= host= path= username=
while IFS= read -r line && [ -n "$line" ]; do
case "$line" in
protocol=*) protocol=${line#protocol=} ;;
host=*) host=${line#host=} ;;
path=*) path=${line#path=} ;;
username=*) username=${line#username=} ;;
esac
done
[ "$protocol" = https ] || exit 1
[ "$host" = git.garvis.dev ] || exit 1
[ "$path" = dmg/purpur-sentinel.git ] || exit 1
[ -z "$username" ] || [ "$username" = dmg ] || exit 1
[ -n "${RELEASE_CREDENTIAL-}" ] || exit 1
LC_ALL=C
export LC_ALL
case "$RELEASE_CREDENTIAL" in *[![:graph:]]*) exit 1 ;; esac
printf '%s\n' 'username=dmg'
printf 'password=%s\n\n' "$RELEASE_CREDENTIAL"
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { test } from "node:test";
const fixture = "synthetic-git-protocol-fixture";
const helper = resolve("scripts/git-credential.sh");
const request = "protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-sentinel.git\n\n";
const environment = {
PATH: process.env.PATH, RELEASE_CREDENTIAL: fixture, LC_ALL: "C",
GIT_ASKPASS: "echo", GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null",
};
test("real Git obtains the credential even when semantic-release forces GIT_ASKPASS=echo", () => {
const child = spawnSync("git", [
"-c", "credential.helper=", "-c", `credential.helper=${helper}`,
"-c", "credential.useHttpPath=true", "credential", "fill",
], { encoding: "utf8", input: request, env: environment });
assert.equal(child.status, 0);
assert.ok(child.stdout.split("\n").includes("username=dmg"));
assert.ok(child.stdout.split("\n").includes(`password=${fixture}`));
assert.equal(child.stderr, "");
});
for (const input of [
request.replace("https", "http"), request.replace("git.garvis.dev", "evil.invalid"),
request.replace("purpur-sentinel.git", "another-project.git"),
request.replace("\n\n", "\nusername=other\n\n"),
]) {
test(`refuses mismatched credential scope: ${JSON.stringify(input)}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], { encoding: "utf8", input, env: environment });
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const action of ["store", "erase"]) {
test(`ignores ${action} without emitting or persisting credentials`, () => {
const child = spawnSync("/bin/sh", [helper, action], {
encoding: "utf8", input: request, env: environment,
});
assert.equal(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const value of ["", "bad\npassword=injected", "trailing-space "]) {
test(`rejects absent or malformed credential input of length ${value.length}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], {
encoding: "utf8", input: request, env: { ...environment, RELEASE_CREDENTIAL: value },
});
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const fixture = "synthetic-cli-fixture";
const entrypoint = resolve("build/release-tools/publish-release.js");
function runPublication(mode: "success" | "transport" | "missing") {
const dir = mkdtempSync(join(tmpdir(), "sentinel-cli-"));
try {
mkdirSync(join(dir, "build/libs"), { recursive: true });
if (mode !== "missing") writeFileSync(join(dir, "build/libs/purpur-sentinel-1.2.3.jar"), Buffer.from([80, 75, 3, 4]));
// A process-local fake at the HTTP boundary: these tests must never contact Gitea.
const preload = join(dir, "http-fixture.mjs");
writeFileSync(preload, `
import assert from 'node:assert/strict';
let calls = 0;
globalThis.fetch = async (url, init) => {
calls++;
assert.equal(new Headers(init.headers).get('authorization'), 'token ' + process.env.RELEASE_CREDENTIAL);
assert.equal(init.redirect, 'error');
assert.ok(!url.includes(process.env.RELEASE_CREDENTIAL));
if (process.env.FIXTURE_MODE === 'transport') throw new Error(process.env.RELEASE_CREDENTIAL);
if (calls === 1) {
const body = JSON.parse(init.body);
assert.equal(body.target_commitish, 'a'.repeat(40));
assert.equal(body.body, 'Fixture notes');
assert.equal(body.draft, true);
} else if (calls === 2) {
assert.deepEqual(new Uint8Array(init.body), new Uint8Array([80, 75, 3, 4]));
assert.ok(url.endsWith('/42/assets?name=purpur-sentinel-1.2.3.jar'));
} else {
assert.equal(calls, 3);
assert.equal(init.method, 'PATCH');
}
return Response.json({id:42, draft: calls !== 3, tag_name:'v1.2.3', name:'purpur-sentinel-1.2.3.jar', size:4});
};
if (process.env.FIXTURE_MODE === 'success') process.on('exit', () => assert.equal(calls, 3));
`);
return spawnSync(process.execPath, ["--import", preload, entrypoint], {
cwd: dir, encoding: "utf8", env: {
VERSION: "1.2.3", RELEASE_COMMIT: "a".repeat(40), RELEASE_CREDENTIAL: fixture,
RELEASE_NOTES: "Fixture notes", FIXTURE_MODE: mode,
},
});
} finally { rmSync(dir, { recursive: true, force: true }); }
}
test("CLI reads only the exact versioned JAR and reports completed publication", () => {
const child = runPublication("success");
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stdout, "Published purpur-sentinel v1.2.3.\n");
assert.equal(child.stderr, "");
});
for (const mode of ["transport", "missing"] as const) {
test(`CLI sanitizes ${mode} failures and never claims publication`, () => {
const child = runPublication(mode);
assert.equal(child.status, 1);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "Release publication failed; inspect the release state before retrying.\n");
});
}
for (const environment of [
{ VERSION: "1.2.3", RELEASE_COMMIT: "a".repeat(40) },
{ VERSION: "../../outside", RELEASE_CREDENTIAL: "synthetic-cli-fixture", RELEASE_COMMIT: "a".repeat(40) },
]) {
test(`CLI fails closed on invalid input without echoing environment: ${Object.keys(environment)}`, () => {
const child = spawnSync(process.execPath, ["build/release-tools/publish-release.js"], {
encoding: "utf8", env: environment,
});
assert.equal(child.status, 1);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "Release publication failed; inspect the release state before retrying.\n");
});
}
+21
View File
@@ -0,0 +1,21 @@
import { readFile } from "node:fs/promises";
import { publishRelease } from "./release-client.js";
try {
const version = process.env.VERSION ?? "";
const credential = process.env.RELEASE_CREDENTIAL ?? "";
const commit = process.env.RELEASE_COMMIT ?? "";
// Check path components and required inputs before opening an artifact or contacting Gitea.
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input");
const jar = await readFile(`build/libs/purpur-sentinel-${version}.jar`);
await publishRelease({
credential, version, commit, jar,
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
}, fetch);
console.log(`Published purpur-sentinel v${version}.`);
} catch {
// Never echo credentials, upstream error bodies, transport diagnostics or the environment.
console.error("Release publication failed; inspect the release state before retrying.");
process.exitCode = 1;
}
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { publishRelease, type ReleaseInput } from "./release-client.js";
const input: ReleaseInput = {
credential: "synthetic-test-value-not-a-real-credential",
version: "1.2.3",
commit: "a".repeat(40),
notes: "Verified changes",
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
};
function success(init: RequestInit): Response {
return Response.json({
id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3",
name: "purpur-sentinel-1.2.3.jar", size: input.jar.length,
});
}
test("creates a draft on the fixed repository and exact source commit, without following redirects", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const id = await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
});
assert.ok(calls.length >= 1);
const first = calls[0]!;
assert.equal(first.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-sentinel/releases");
assert.equal(first.init.method, "POST");
assert.equal(first.init.redirect, "error");
assert.equal(new Headers(first.init.headers).get("authorization"), `token ${input.credential}`);
assert.deepEqual(JSON.parse(String(first.init.body)), {
tag_name: "v1.2.3", target_commitish: input.commit, name: "v1.2.3",
body: input.notes, draft: true, prerelease: false,
});
assert.equal(id, 42);
});
test("uploads the exact versioned bytes before publishing the draft", async () => {
const calls: { url: string; init: RequestInit }[] = [];
await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
});
assert.equal(calls.length, 3);
assert.equal(calls[1]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-sentinel/releases/42/assets?name=purpur-sentinel-1.2.3.jar");
assert.equal(calls[1]!.init.method, "POST");
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream");
assert.equal(calls[2]!.init.method, "PATCH");
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-sentinel/releases/42");
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
for (const call of calls) {
assert.equal(call.init.redirect, "error");
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
assert.ok(!call.url.includes(input.credential));
}
});
for (const failureStep of [1, 2, 3]) {
for (const status of [302, 401, 409, 500]) {
test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
if (calls === failureStep) return new Response(input.credential, { status });
return success(init);
}), (error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(error.message.includes(String(status)));
assert.ok(!error.message.includes(input.credential));
return true;
});
assert.equal(calls, failureStep, "Do not retry, overwrite an asset or publish after a failed step");
});
}
}
test("sanitizes transport errors and does not follow a redirect with the credential", async () => {
await assert.rejects(publishRelease(input, async () => {
throw new Error(`diagnostic containing ${input.credential}`);
}), { message: "Release API request failed." });
});
for (const invalid of [
{ credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" },
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() },
]) {
test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => {
let calls = 0;
await assert.rejects(publishRelease({ ...input, ...invalid }, async () => {
calls++;
return Response.json({ id: 42 });
}), { message: "Invalid release input." });
assert.equal(calls, 0);
});
}
for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringify({ id: "../other" }),
JSON.stringify({ id: 42, draft: false, tag_name: "v1.2.3" })]) {
test(`rejects malformed or already-public draft response: ${body}`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async () => {
calls++;
return new Response(body);
}), { message: "Invalid draft release response." });
assert.equal(calls, 1);
});
}
for (const [failureStep, body] of [
[2, {}], [2, { name: "other.jar", size: 4 }],
[2, { name: "purpur-sentinel-1.2.3.jar", size: 0 }],
[3, { id: 42, draft: true, tag_name: "v1.2.3" }],
[3, { id: 77, draft: false, tag_name: "v1.2.3" }],
[3, { id: 42, draft: false, tag_name: "v8.8.8" }],
] as const) {
test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
return calls === failureStep ? Response.json(body) : success(init);
}), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." });
assert.equal(calls, failureStep);
});
}
+70
View File
@@ -0,0 +1,70 @@
export interface ReleaseInput {
credential: string;
version: string;
commit: string;
notes: string;
jar: Uint8Array;
}
export type Request = (url: string, init: RequestInit) => Promise<Response>;
const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-sentinel/releases";
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
if (!/^[\x21-\x7e]+$/.test(input.credential)
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) {
throw new Error("Invalid release input.");
}
const send: Request = async (url, init) => {
let response: Response;
try {
response = await request(url, { ...init, signal: AbortSignal.timeout(60_000) });
} catch {
throw new Error("Release API request failed.");
}
if (!response.ok) throw new Error(`Release API request failed (HTTP ${response.status}).`);
return response;
};
const response = await send(releases, {
method: "POST", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
body: JSON.stringify({
tag_name: `v${input.version}`, target_commitish: input.commit, name: `v${input.version}`,
body: input.notes, draft: true, prerelease: false,
}),
});
const release = await readObject(response, "Invalid draft release response.");
if (typeof release.id !== "number" || !Number.isSafeInteger(release.id) || release.id <= 0
|| release.draft !== true || release.tag_name !== `v${input.version}`) {
throw new Error("Invalid draft release response.");
}
const assetResponse = await send(`${releases}/${release.id}/assets?name=purpur-sentinel-${input.version}.jar`, {
method: "POST", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" },
body: new Uint8Array(input.jar).buffer,
});
const asset = await readObject(assetResponse, "Invalid release asset response.");
if (asset.name !== `purpur-sentinel-${input.version}.jar` || asset.size !== input.jar.length) {
throw new Error("Invalid release asset response.");
}
const publishedResponse = await send(`${releases}/${release.id}`, {
method: "PATCH", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
body: JSON.stringify({ draft: false }),
});
const published = await readObject(publishedResponse, "Invalid published release response.");
if (published.id !== release.id || published.draft !== false || published.tag_name !== `v${input.version}`) {
throw new Error("Invalid published release response.");
}
return release.id;
}
async function readObject(response: Response, message: string): Promise<Record<string, unknown>> {
let value: unknown;
try {
value = await response.json();
} catch {
throw new Error(message);
}
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(message);
return value as Record<string, unknown>;
}
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { closeSync, chmodSync, mkdtempSync, openSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { planSecretInput } from "./secret-input.js";
const fixture = "synthetic-provisioning-value-not-a-real-secret";
function protectedInput(body: (dir: string, file: string) => void) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "sentinel-secret-contract-")));
const file = join(dir, "release-token");
try {
chmodSync(dir, 0o700);
writeFileSync(file, fixture, { mode: 0o600 });
body(dir, file);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
for (const invalid of ["public-file", "public-directory", "symlink", "relative", "repository", "missing", "directory", "empty", "login"] as const) {
test(`protected provisioning plan refuses ${invalid} input without reading or echoing it`, () => protectedInput((dir, file) => {
let path = file;
let root = resolve(".");
let login = "fixture-login";
if (invalid === "public-file") chmodSync(file, 0o644);
if (invalid === "public-directory") chmodSync(dir, 0o755);
if (invalid === "symlink") { path = join(dir, "link"); symlinkSync(file, path); }
if (invalid === "relative") path = "relative-token";
if (invalid === "repository") root = dir;
if (invalid === "missing") path = join(dir, "missing");
if (invalid === "directory") path = dir;
if (invalid === "empty") writeFileSync(file, "");
if (invalid === "login") login = "--debug";
assert.throws(() => planSecretInput(login, path, "file", root), {
message: "Invalid protected secret input; no provisioning was performed.",
});
}));
}
for (const mode of ["file", "stdin"] as const) {
test(`protected ${mode} plan follows tea contract without credential argv, logs or actual provisioning`, () => protectedInput((dir, file) => {
const plan = planSecretInput("fixture-login", file, mode, resolve("."));
assert.equal(plan.command, "tea");
assert.deepEqual(plan.args, ["actions", "secrets", "create", "--login", "fixture-login", "--repo", "dmg/purpur-sentinel",
...(mode === "file" ? ["--file", file] : ["--stdin"]), "RELEASE_TOKEN"]);
assert.equal(plan.stdinFile, mode === "stdin" ? file : undefined);
assert.ok(!JSON.stringify(plan).includes(fixture));
// Execute only an isolated local fake, NEVER the installed tea command.
const fake = join(dir, "fake-tea.cjs");
writeFileSync(fake, `
const fs = require('node:fs');
const args = process.argv.slice(2);
const bytes = args.includes('--stdin') ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args[args.indexOf('--file') + 1], 'utf8');
if (bytes !== ${JSON.stringify(fixture)} || args.join(' ').includes(bytes)) process.exit(1);
process.stdout.write('Accepted protected input for RELEASE_TOKEN.\\n');
`, { mode: 0o600 });
const fd = plan.stdinFile ? openSync(plan.stdinFile, "r") : undefined;
try {
const child = spawnSync(process.execPath, [fake, ...plan.args], {
env: {}, encoding: "utf8", stdio: [fd ?? "ignore", "pipe", "pipe"],
});
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stdout, "Accepted protected input for RELEASE_TOKEN.\n");
assert.equal(child.stderr, "");
} finally { if (fd !== undefined) closeSync(fd); }
}));
}
+35
View File
@@ -0,0 +1,35 @@
import { lstatSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
export interface SecretInputPlan {
command: "tea";
args: string[];
stdinFile?: string;
}
// Plan only: never reads secret bytes, spawns tea, or provisions a secret.
// This is an inspectable contract, not upload authorization or a race-free open.
// An operator must recheck metadata and approve the actual upload separately.
export function planSecretInput(login: string, file: string, mode: "file" | "stdin", repositoryRoot: string): SecretInputPlan {
try {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(login) || !isAbsolute(file)
|| (mode !== "file" && mode !== "stdin")) throw new Error();
const canonical = realpathSync(file);
const local = relative(realpathSync(repositoryRoot), canonical);
if (canonical !== resolve(file) || !(local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local))) throw new Error();
const metadata = lstatSync(file);
const directory = lstatSync(dirname(file));
const owner = process.getuid?.();
if (owner === undefined || !metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0
|| metadata.uid !== owner || (metadata.mode & 0o777) !== 0o600
|| !directory.isDirectory() || directory.uid !== owner || (directory.mode & 0o777) !== 0o700) throw new Error();
} catch {
throw new Error("Invalid protected secret input; no provisioning was performed.");
}
return {
command: "tea",
args: ["actions", "secrets", "create", "--login", login, "--repo", "dmg/purpur-sentinel",
...(mode === "file" ? ["--file", file] : ["--stdin"]), "RELEASE_TOKEN"],
...(mode === "stdin" ? { stdinFile: file } : {}),
};
}
+199
View File
@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
const root = resolve(".");
const sha = "a".repeat(40);
const fixture = "synthetic-workflow-credential";
function workflow(name: string): string { return readFileSync(join(root, `.gitea/workflows/${name}.yml`), "utf8"); }
function step(name: string, title: string): string {
const value = workflow(name).split(" - name: ").find(block => block.startsWith(title + "\n"));
assert.ok(value, `Missing workflow step: ${title}`);
return value;
}
function script(name: string, title: string): string {
const block = step(name, title);
const run = block.split(" run: ")[1];
assert.ok(run, `Missing run body: ${title}`);
return run.startsWith("|\n") ? run.slice(2).split("\n").map(line => line.replace(/^ /, "")).join("\n") : run.trim();
}
function sandbox(body: (dir: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "sentinel-workflow-"));
try { body(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
}
function fake(dir: string, command: string, source: string): void {
mkdirSync(join(dir, "bin"), { recursive: true });
writeFileSync(join(dir, "bin", command), `#!${process.execPath}\n${source}`, { mode: 0o700 });
}
function shell(dir: string, source: string, env: NodeJS.ProcessEnv = {}) {
return spawnSync("/bin/sh", ["-eu", "-c", source], { cwd: dir, encoding: "utf8", env: {
PATH: `${join(dir, "bin")}:${process.env.PATH}`, HOME: dir, GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null", GITHUB_WORKSPACE: root, GITHUB_OUTPUT: join(dir, "output"), ...env,
} });
}
test("CI runs the full check and commitlint with inert SHA arguments and SHA-named artifacts", () => {
sandbox(dir => {
writeFileSync(join(dir, "build-args"), "");
writeFileSync(join(dir, "gradlew"), '#!/bin/sh\nprintf "%s\\n" "$@" > build-args\n', { mode: 0o700 });
assert.equal(shell(dir, script("ci", "Build and test")).status, 0);
assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n");
fake(dir, "npx", `require('node:fs').writeFileSync('lint-args', JSON.stringify(process.argv.slice(2)));`);
const child = shell(dir, script("ci", "Validate conventional commits"), { BASE_SHA: "b".repeat(40), HEAD_SHA: sha });
assert.equal(child.status, 0, child.stderr);
const args = JSON.parse(readFileSync(join(dir, "lint-args"), "utf8"));
assert.deepEqual(args.slice(-6), ["--from", "b".repeat(40), "--to", sha, "--extends", "@commitlint/config-conventional"]);
assert.match(step("ci", "Validate conventional commits"), /if: github.event_name == 'pull_request'/);
assert.match(step("ci", "Upload development artifact"), /name: purpur-sentinel-\$\{\{ github.sha \}\}/);
assert.match(step("ci", "Upload development artifact"), /path: build\/libs\/purpur-sentinel-\*\.jar/);
assert.match(step("ci", "Upload development artifact"), /if-no-files-found: error/);
assert.match(workflow("ci"), /java-version: 25/);
assert.match(workflow("ci"), /node-version: 20/);
});
});
test("release performs full snapshot and versioned checks and gates versioned artifacts on a new release", () => sandbox(dir => {
writeFileSync(join(dir, "build-args"), "");
writeFileSync(join(dir, "gradlew"), '#!/bin/sh\nprintf "%s\\n" "$@" > build-args\n', { mode: 0o700 });
assert.equal(shell(dir, script("release", "Build and test before release")).status, 0);
assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n");
assert.equal(shell(dir, script("release", "Verify versioned plugin and release tooling"), { VERSION: "1.2.3" }).status, 0);
assert.equal(readFileSync(join(dir, "build-args"), "utf8"), "clean\ncheck\njar\n-PreleaseVersion=1.2.3\n");
for (const title of ["Verify versioned plugin and release tooling", "Upload release workflow artifact"]) {
assert.match(step("release", title), /if: steps.release.outputs.created == 'true'/);
}
const release = workflow("release");
assert.ok(release.indexOf("Build and test before release") < release.indexOf("Compute and push"));
assert.ok(release.indexOf("Capture previous tag") < release.indexOf("Compute and push"));
assert.ok(release.indexOf("Verify versioned") < release.indexOf("Upload release workflow artifact"));
assert.match(release, /branches: \[main\]/);
assert.match(release, /group: sentinel-release\n cancel-in-progress: false/);
assert.match(step("release", "Upload release workflow artifact"), /path: build\/libs\/purpur-sentinel-\$\{\{ steps.release.outputs.version \}\}\.jar/);
}));
for (const scenario of ["new", "none", "unchanged", "wrong-source", "invalid-tag"] as const) {
test(`release resolution proves exact source and does not claim no-release publication: ${scenario}`, () => sandbox(dir => {
const init = shell(dir, "git init -q; git -c user.name=Fixture -c user.email=fixture@example.invalid commit --allow-empty -qm 'feat: fixture'");
assert.equal(init.status, 0, init.stderr);
if (scenario === "unchanged") assert.equal(shell(dir, "git tag v1.2.3").status, 0);
writeFileSync(join(dir, "output"), "");
const previous = shell(dir, script("release", "Capture previous tag"));
assert.equal(previous.status, 0, previous.stderr);
const previousOutput = readFileSync(join(dir, "output"), "utf8");
assert.equal(previousOutput, scenario === "unchanged" ? "tag=v1.2.3\n" : "tag=\n");
writeFileSync(join(dir, "output"), "");
if (scenario === "new" || scenario === "wrong-source") assert.equal(shell(dir, "git tag v1.2.3").status, 0);
if (scenario === "invalid-tag") assert.equal(shell(dir, "git tag v01.2.3-unsupported").status, 0);
const head = shell(dir, "git rev-parse HEAD").stdout.trim();
const child = shell(dir, script("release", "Resolve newly created exact-source release"), {
PREVIOUS_TAG: scenario === "unchanged" ? "v1.2.3" : "", EXPECTED_COMMIT: scenario === "wrong-source" ? sha : head,
});
const output = readFileSync(join(dir, "output"), "utf8");
if (scenario === "wrong-source" || scenario === "invalid-tag") {
assert.notEqual(child.status, 0);
assert.ok(!output.includes("created=true"));
} else {
assert.equal(child.status, 0, child.stderr);
assert.equal(output, scenario === "new" ? "created=true\nversion=1.2.3\n" : "created=false\n");
}
}));
}
const forbiddenEnv = [
"GIT_CREDENTIALS", "GH_TOKEN", "GITHUB_TOKEN", "GL_TOKEN", "GITLAB_TOKEN", "BB_TOKEN", "BITBUCKET_TOKEN",
"BB_TOKEN_BASIC_AUTH", "BITBUCKET_TOKEN_BASIC_AUTH", "DEBUG", "NODE_DEBUG", "GIT_TRACE", "GIT_TRACE_CURL",
"GIT_CURL_VERBOSE", "GIT_TRACE_PACKET", "GIT_TRACE_SETUP", "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF",
];
test("publication invokes only the in-process publisher with no secret argv or tracing", () => sandbox(dir => {
assert.equal(shell(dir, "git init -q; git -c user.name=Fixture -c user.email=fixture@example.invalid commit --allow-empty -qm 'feat: fixture'").status, 0);
writeFileSync(join(dir, "publish-command"), "null");
fake(dir, "node", `
const assert = require('node:assert/strict');
for (const key of ${JSON.stringify(forbiddenEnv)}) assert.equal(process.env[key], undefined, key);
assert.equal(process.env.RELEASE_CREDENTIAL, ${JSON.stringify(fixture)});
assert.match(process.env.RELEASE_NOTES, /feat: fixture/);
require('node:fs').writeFileSync('publish-command', JSON.stringify(process.argv.slice(2)));
`);
const child = shell(dir, script("release", "Publish verified JAR through a draft release"), {
RELEASE_CREDENTIAL: fixture, PREVIOUS_TAG: "",
...Object.fromEntries(forbiddenEnv.map(key => [key, key.includes("TOKEN") ? fixture : "1"])),
});
assert.equal(child.status, 0, child.stderr);
assert.deepEqual(JSON.parse(readFileSync(join(dir, "publish-command"), "utf8")), ["build/release-tools/publish-release.js"]);
assert.ok(![child.stdout, child.stderr].join("").includes(fixture));
assert.match(step("release", "Publish verified JAR through a draft release"), /if: steps.release.outputs.created == 'true'/);
assert.ok(workflow("release").indexOf("Verify versioned") < workflow("release").indexOf("Publish verified"));
assert.doesNotMatch(workflow("release"), /curl|secrets\.RELEASE_TOKEN[^\n]*(?:curl|git|node)/);
}));
test("semantic tagging keeps the dummy credential only in helper environment, never argv, URLs or diagnostics", () => {
sandbox(dir => {
assert.equal(shell(dir, "git init -q").status, 0);
writeFileSync(join(dir, "tag-command"), "null");
fake(dir, "npx", `
const assert = require('node:assert/strict');
for (const key of ${JSON.stringify(forbiddenEnv)}) assert.equal(process.env[key], undefined, key);
assert.equal(process.env.RELEASE_CREDENTIAL, ${JSON.stringify(fixture)});
assert.equal(process.env.GIT_TERMINAL_PROMPT, '0');
require('node:fs').writeFileSync('tag-command', JSON.stringify(process.argv.slice(2)));
`);
const child = shell(dir, script("release", "Compute and push semantic release tag"), {
RELEASE_CREDENTIAL: fixture, REPOSITORY: "dmg/purpur-sentinel",
...Object.fromEntries(forbiddenEnv.map(key => [key, key.includes("TOKEN") ? fixture : "1"])),
});
assert.equal(child.status, 0, child.stderr);
const args = JSON.parse(readFileSync(join(dir, "tag-command"), "utf8"));
assert.ok(Array.isArray(args), "Must actually invoke semantic-release");
assert.ok(args.includes("semantic-release@24.2.9"));
assert.ok(args.includes("@semantic-release/commit-analyzer@13.0.1"));
assert.ok(args.includes("@semantic-release/release-notes-generator@14.1.0"));
assert.equal(args[args.indexOf("--repository-url") + 1], "https://git.garvis.dev/dmg/purpur-sentinel.git");
const config = shell(dir, "git config --local --list");
assert.match(config.stdout, /credential.usehttppath=true/);
assert.match(config.stdout, /http.followredirects=false/);
assert.ok(config.stdout.includes(`credential.helper=${root}/scripts/git-credential.sh`));
assert.ok(![JSON.stringify(args), config.stdout, child.stdout, child.stderr].join("").includes(fixture));
});
});
for (const env of [{ RELEASE_CREDENTIAL: "", REPOSITORY: "dmg/purpur-sentinel" },
{ RELEASE_CREDENTIAL: fixture, REPOSITORY: "evil/purpur-sentinel" }]) {
test(`tagging refuses missing credential or wrong repository: ${env.REPOSITORY}`, () => sandbox(dir => {
fake(dir, "npx", "process.exit(90);");
const child = shell(dir, script("release", "Compute and push semantic release tag"), env);
assert.notEqual(child.status, 0);
assert.notEqual(child.status, 90);
assert.ok(!child.stderr.includes(fixture));
}));
}
for (const name of ["ci", "release"]) {
test(`${name} rejects multiline source SHAs before invoking Git`, () => sandbox(dir => {
writeFileSync(join(dir, "calls"), "");
fake(dir, "git", `require('node:fs').appendFileSync('calls', 'called');`);
const child = shell(dir, script(name, "Checkout public source without a credential"), { SOURCE_SHA: `${sha}\nmalformed` });
assert.notEqual(child.status, 0);
assert.equal(readFileSync(join(dir, "calls"), "utf8"), "");
}));
}
for (const name of ["ci", "release"]) test(`${name} anonymously fetches full history and exact source without credentials or redirects`, () => {
sandbox(dir => {
writeFileSync(join(dir, "calls"), "");
fake(dir, "git", `require('node:fs').appendFileSync('calls', JSON.stringify(process.argv.slice(2))+'\\n');`);
const child = shell(dir, script(name, "Checkout public source without a credential"), { SOURCE_SHA: sha });
assert.equal(child.status, 0, child.stderr);
const calls = readFileSync(join(dir, "calls"), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line));
assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["remote", "add", "origin", "https://git.garvis.dev/dmg/purpur-sentinel.git"])));
for (const [key, value] of [["credential.helper", ""], ["http.extraHeader", ""], ["http.followRedirects", "false"]]) {
assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["config", "--local", key, value])));
}
assert.ok(calls.some(args => JSON.stringify(args) === JSON.stringify(["fetch", "--force", "--tags", "origin", "+refs/heads/*:refs/remotes/origin/*"])));
assert.deepEqual(calls.at(-1), name === "ci" ? ["checkout", "--detach", sha] : ["checkout", "-B", "main", sha]);
assert.doesNotMatch(step(name, "Checkout public source without a credential"), /secrets\.|token:/);
});
});
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "purpur-sentinel"
@@ -0,0 +1,6 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) {
}
@@ -0,0 +1,72 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import org.bukkit.configuration.file.YamlConfiguration;
public final class AtomicYamlSettingsStore implements SettingsPersistence {
private final Path file;
public AtomicYamlSettingsStore(Path file) {
this.file = file;
}
public static void writeDefaults(Path file) throws IOException {
if (Files.notExists(file)) {
Files.createDirectories(file.getParent());
Files.writeString(file, """
horizontal-speed-limit: 0.80
breaches-to-alert: 6
evidence-window-seconds: 10
alert-cooldown-seconds: 60
minimum-tps: 18.0
exemption-grace-ticks: 20
""", StandardOpenOption.CREATE_NEW);
}
}
@Override public SettingsDocument load() throws IOException {
if (Files.notExists(file)) {
throw new IOException("Missing Sentinel configuration: " + file);
}
var yaml = YamlConfiguration.loadConfiguration(file.toFile());
var values = new LinkedHashMap<String, Object>();
for (String key : yaml.getKeys(false)) {
values.put(key, yaml.get(key));
}
return new SettingsDocument(values);
}
@Override public void save(SettingsDocument document) throws IOException {
var yaml = new YamlConfiguration();
document.values().forEach(yaml::set);
byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8);
Path parent = file.toAbsolutePath().getParent();
Files.createDirectories(parent);
Path temporary = Files.createTempFile(parent, file.getFileName().toString(), ".tmp");
boolean moved = false;
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
try {
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException("Atomic configuration replacement is not supported", exception);
}
moved = true;
} finally {
if (!moved) Files.deleteIfExists(temporary);
}
}
}
@@ -0,0 +1,118 @@
package games.dmg.purpursentinel;
import java.util.Objects;
import java.util.function.BiPredicate;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.entity.Pose;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityKnockbackEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
public final class BukkitMovementListener implements Listener {
private final Server server;
private final MovementMonitor monitor;
private final Supplier<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final BiPredicate<Location, Location> ordinaryEnvironment;
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime,
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to));
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.ordinaryEnvironment = ordinaryEnvironment;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void onMove(PlayerMoveEvent event) {
if (!event.hasExplicitlyChangedPosition()) return;
Location from = event.getFrom();
Location to = event.getTo();
var player = event.getPlayer();
ExclusionReason reason = classify(player, from, to, event.isCancelled());
monitor.record(player.getUniqueId(), new MovementFrame(
server.getCurrentTick(),
nanoTime.getAsLong(),
Objects.requireNonNull(from.getWorld()).getUID(),
Objects.requireNonNull(to.getWorld()).getUID(),
from.getX(), from.getZ(), to.getX(), to.getZ(), reason), settings.get());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void onTeleport(PlayerTeleportEvent event) { exempt(event.getPlayer(), ExclusionReason.LIFECYCLE); }
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) { exempt(event.getPlayer(), ExclusionReason.LIFECYCLE); }
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) { exempt(event.getPlayer(), ExclusionReason.LIFECYCLE); }
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldChange(PlayerChangedWorldEvent event) { exempt(event.getPlayer(), ExclusionReason.LIFECYCLE); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onVelocity(PlayerVelocityEvent event) { exempt(event.getPlayer(), ExclusionReason.VELOCITY); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onKnockback(EntityKnockbackEvent event) {
if (event.getEntity() instanceof Player player) exempt(player, ExclusionReason.VELOCITY);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) exempt(player, ExclusionReason.DAMAGE);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) { monitor.forget(event.getPlayer().getUniqueId()); }
private void exempt(Player player, ExclusionReason reason) {
monitor.exempt(player.getUniqueId(), server.getCurrentTick(), nanoTime.getAsLong(), reason, settings.get());
}
private ExclusionReason classify(Player player, Location from, Location to, boolean cancelled) {
if (cancelled) return ExclusionReason.CANCELLED;
if (from.getWorld() == null || to.getWorld() == null || !from.getWorld().equals(to.getWorld())) return ExclusionReason.INCOMPLETE;
if (!finite(from.getX(), from.getZ(), to.getX(), to.getZ())) return ExclusionReason.INCOMPLETE;
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
if (player.getGameMode() != GameMode.SURVIVAL && player.getGameMode() != GameMode.ADVENTURE) return ExclusionReason.GAME_MODE;
if (player.isInsideVehicle()) return ExclusionReason.VEHICLE;
if (player.isFlying()) return ExclusionReason.FLYING;
if (player.isGliding() || player.getPose() == Pose.FALL_FLYING) return ExclusionReason.GLIDING;
if (player.isSwimming() || player.getPose() == Pose.SWIMMING || player.isInWater()
|| player.isInLava() || player.isInBubbleColumn()) return ExclusionReason.SWIMMING;
if (player.isClimbing()) return ExclusionReason.CLIMBING;
if (player.isRiptiding() || player.getPose() == Pose.SPIN_ATTACK) return ExclusionReason.RIPTIDE;
if (player.getActivePotionEffects().stream().map(effect -> effect.getType().getKey().getKey())
.anyMatch(key -> key.equals("speed") || key.equals("slowness"))) {
return ExclusionReason.POTION_EFFECT;
}
if (!ordinaryEnvironment.test(from, to)) return ExclusionReason.ENVIRONMENT;
return ExclusionReason.NONE;
}
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
}
@@ -0,0 +1,30 @@
package games.dmg.purpursentinel;
public enum ExclusionReason {
NONE("none"),
CANCELLED("cancelled movement"),
INCOMPLETE("incomplete movement"),
GAME_MODE("game mode"),
VEHICLE("vehicle"),
FLYING("flight"),
GLIDING("gliding"),
SWIMMING("swimming or crawling"),
CLIMBING("climbing"),
RIPTIDE("riptide"),
POTION_EFFECT("speed or slowness effect"),
ENVIRONMENT("movement-altering environment"),
LOW_TPS("low server TPS"),
LIFECYCLE("join, teleport, respawn, or world change"),
VELOCITY("server-applied velocity or knockback"),
DAMAGE("damage or explosion");
private final String description;
ExclusionReason(String description) {
this.description = description;
}
public String description() {
return description;
}
}
@@ -0,0 +1,10 @@
package games.dmg.purpursentinel;
public record InspectionSnapshot(
int breachCount,
double evidenceAgeSeconds,
double peakSpeed,
double capturedLimit,
ExclusionReason currentExclusion,
double cooldownRemainingSeconds) {
}
@@ -0,0 +1,119 @@
package games.dmg.purpursentinel;
import java.util.ArrayDeque;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class MovementDetector {
static final int MAX_EVIDENCE_PER_PLAYER = 1_200;
private final Map<UUID, PlayerState> players = new HashMap<>();
public Optional<AlertDecision> observe(UUID playerId, MovementSample sample, SentinelSettings settings) {
var state = players.computeIfAbsent(playerId, ignored -> new PlayerState());
expire(state, sample.observedAtNanos());
if (!sample.complete() || sample.cancelled() || sample.exclusionReason() != ExclusionReason.NONE
|| !Double.isFinite(sample.horizontalDistance()) || sample.horizontalDistance() < 0) {
state.currentExclusion = sample.cancelled() ? ExclusionReason.CANCELLED
: sample.exclusionReason() == ExclusionReason.NONE ? ExclusionReason.INCOMPLETE : sample.exclusionReason();
state.graceThroughTick = Math.max(state.graceThroughTick, sample.tick() + settings.exemptionGraceTicks());
return Optional.empty();
}
if (sample.tick() <= state.graceThroughTick) {
return Optional.empty();
}
state.currentExclusion = ExclusionReason.NONE;
if (sample.horizontalDistance() <= settings.horizontalSpeedLimit()) {
return Optional.empty();
}
if (state.evidence.size() == MAX_EVIDENCE_PER_PLAYER) {
state.evidence.removeFirst();
}
state.evidence.addLast(new Breach(
sample.observedAtNanos(),
sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()),
sample.horizontalDistance(),
settings.horizontalSpeedLimit()));
if (state.evidence.size() < settings.breachesToAlert() || sample.observedAtNanos() < state.cooldownUntilNanos) {
return Optional.empty();
}
state.cooldownUntilNanos = sample.observedAtNanos() + seconds(settings.alertCooldownSeconds());
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new AlertDecision(playerId, state.evidence.size(), peak.speed(), peak.capturedLimit()));
}
public void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
var state = players.computeIfAbsent(playerId, ignored -> new PlayerState());
expire(state, nowNanos);
state.currentExclusion = reason;
state.graceThroughTick = Math.max(state.graceThroughTick, currentTick + settings.exemptionGraceTicks());
}
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
var state = players.get(playerId);
if (state == null) {
return Optional.empty();
}
expire(state, nowNanos);
if (state.evidence.isEmpty()) {
return Optional.empty();
}
var first = state.evidence.getFirst();
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new InspectionSnapshot(
state.evidence.size(),
Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0,
peak.speed(),
peak.capturedLimit(),
state.currentExclusion,
Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0));
}
public void cleanup(int currentTick, long nowNanos) {
players.entrySet().removeIf(entry -> {
var state = entry.getValue();
expire(state, nowNanos);
return state.evidence.isEmpty() && state.cooldownUntilNanos <= nowNanos
&& currentTick > state.graceThroughTick;
});
}
public void forget(UUID playerId) {
players.remove(playerId);
}
public void clear() {
players.clear();
}
int trackedPlayerCount() {
return players.size();
}
int evidenceCount(UUID playerId) {
var state = players.get(playerId);
return state == null ? 0 : state.evidence.size();
}
private static long seconds(int value) {
return value * 1_000_000_000L;
}
private static void expire(PlayerState state, long nowNanos) {
while (!state.evidence.isEmpty() && state.evidence.getFirst().expiresAtNanos() <= nowNanos) {
state.evidence.removeFirst();
}
}
private record Breach(long observedAtNanos, long expiresAtNanos, double speed, double capturedLimit) {
}
private static final class PlayerState {
private final ArrayDeque<Breach> evidence = new ArrayDeque<>();
private int graceThroughTick = Integer.MIN_VALUE;
private long cooldownUntilNanos;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
}
}
@@ -0,0 +1,64 @@
package games.dmg.purpursentinel;
import java.util.Set;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.Waterlogged;
final class MovementEnvironment {
private static final Set<Material> ALTERING = Set.of(
Material.SLIME_BLOCK,
Material.HONEY_BLOCK,
Material.SOUL_SAND,
Material.SOUL_SOIL,
Material.BUBBLE_COLUMN,
Material.PISTON,
Material.STICKY_PISTON,
Material.PISTON_HEAD,
Material.MOVING_PISTON,
Material.COBWEB,
Material.POWDER_SNOW,
Material.SCAFFOLDING,
Material.SWEET_BERRY_BUSH);
private MovementEnvironment() {
}
static boolean isOrdinary(Location location) {
var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
return false;
}
int baseX = location.getBlockX();
int baseY = location.getBlockY();
int baseZ = location.getBlockZ();
for (int x = baseX - 1; x <= baseX + 1; x++) {
for (int z = baseZ - 1; z <= baseZ + 1; z++) {
for (int y = baseY - 1; y <= baseY + 1; y++) {
var block = world.getBlockAt(x, y, z);
var type = block.getType();
if (ALTERING.contains(type) || isIce(type) || isClimbable(type) || block.isLiquid()) {
return false;
}
if (block.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()) {
return false;
}
}
}
}
return true;
}
private static boolean isIce(Material type) {
return type == Material.ICE || type == Material.PACKED_ICE
|| type == Material.BLUE_ICE || type == Material.FROSTED_ICE;
}
private static boolean isClimbable(Material type) {
String name = type.name();
return type == Material.LADDER || type == Material.VINE || type == Material.SCAFFOLDING
|| name.equals("WEEPING_VINES") || name.equals("WEEPING_VINES_PLANT")
|| name.equals("TWISTING_VINES") || name.equals("TWISTING_VINES_PLANT")
|| name.equals("CAVE_VINES") || name.equals("CAVE_VINES_PLANT");
}
}
@@ -0,0 +1,18 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record MovementFrame(
int tick,
long observedAtNanos,
UUID fromWorld,
UUID toWorld,
double fromX,
double fromZ,
double toX,
double toZ,
ExclusionReason reason) {
public MovementFrame withReason(ExclusionReason replacement) {
return new MovementFrame(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, replacement);
}
}
@@ -0,0 +1,162 @@
package games.dmg.purpursentinel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class MovementMonitor {
private static final double CHAIN_TOLERANCE = 1.0e-6;
private final MovementDetector detector;
private final Map<UUID, TickBucket> pending = new HashMap<>();
public MovementMonitor(MovementDetector detector) {
this.detector = detector;
}
public void record(UUID playerId, MovementFrame frame, SentinelSettings settings) {
var existing = pending.get(playerId);
if (existing != null && existing.tick != frame.tick()) {
finish(playerId, existing);
pending.remove(playerId);
existing = null;
}
if (existing == null) {
pending.put(playerId, TickBucket.start(frame, settings));
} else {
existing.add(frame);
}
}
public List<AlertDecision> finishBefore(int currentTick, long nowNanos) {
var alerts = new ArrayList<AlertDecision>();
var iterator = pending.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
if (entry.getValue().tick < currentTick) {
finish(entry.getKey(), entry.getValue()).ifPresent(alerts::add);
iterator.remove();
}
}
detector.cleanup(currentTick, nowNanos);
return List.copyOf(alerts);
}
public void exempt(UUID playerId, int tick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
pending.remove(playerId);
detector.exempt(playerId, tick, nowNanos, reason, settings);
}
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
return detector.inspect(playerId, nowNanos);
}
public void forget(UUID playerId) {
pending.remove(playerId);
detector.forget(playerId);
}
public void clear() {
pending.clear();
detector.clear();
}
int trackedPlayerCount() {
return detector.trackedPlayerCount();
}
private Optional<AlertDecision> finish(UUID playerId, TickBucket bucket) {
return detector.observe(playerId, new MovementSample(
bucket.tick,
bucket.observedAtNanos,
!bucket.invalid,
bucket.reason == ExclusionReason.CANCELLED,
bucket.maximumDistance,
bucket.reason), bucket.settings);
}
private static final class TickBucket {
private final int tick;
private final long observedAtNanos;
private final UUID world;
private final double anchorX;
private final double anchorZ;
private final SentinelSettings settings;
private double lastX;
private double lastZ;
private double previousFromX;
private double previousFromZ;
private double previousToX;
private double previousToZ;
private boolean hasPrevious;
private double maximumDistance;
private ExclusionReason reason;
private boolean invalid;
private TickBucket(MovementFrame frame, SentinelSettings settings) {
tick = frame.tick();
observedAtNanos = frame.observedAtNanos();
world = frame.fromWorld();
anchorX = frame.fromX();
anchorZ = frame.fromZ();
this.settings = settings;
lastX = frame.fromX();
lastZ = frame.fromZ();
reason = ExclusionReason.NONE;
add(frame);
}
static TickBucket start(MovementFrame frame, SentinelSettings settings) {
return new TickBucket(frame, settings);
}
void add(MovementFrame frame) {
if (!world.equals(frame.toWorld()) || !world.equals(frame.fromWorld())
|| !finite(frame.fromX(), frame.fromZ(), frame.toX(), frame.toZ())
|| !connected(frame.fromX(), frame.fromZ())) {
invalid = true;
reason = ExclusionReason.INCOMPLETE;
return;
}
if (frame.reason() != ExclusionReason.NONE) {
invalid = true;
reason = frame.reason();
}
maximumDistance = Math.max(maximumDistance, Math.hypot(frame.toX() - anchorX, frame.toZ() - anchorZ));
if (!duplicate(frame)) {
lastX = frame.toX();
lastZ = frame.toZ();
}
previousFromX = frame.fromX();
previousFromZ = frame.fromZ();
previousToX = frame.toX();
previousToZ = frame.toZ();
hasPrevious = true;
}
private boolean connected(double x, double z) {
return near(x, anchorX) && near(z, anchorZ)
|| near(x, lastX) && near(z, lastZ)
|| hasPrevious && near(x, previousFromX) && near(z, previousFromZ);
}
private boolean duplicate(MovementFrame frame) {
return hasPrevious
&& near(frame.fromX(), previousFromX) && near(frame.fromZ(), previousFromZ)
&& near(frame.toX(), previousToX) && near(frame.toZ(), previousToZ);
}
private static boolean near(double left, double right) {
return Math.abs(left - right) <= CHAIN_TOLERANCE;
}
private static boolean finite(double... values) {
for (double value : values) {
if (!Double.isFinite(value)) return false;
}
return true;
}
}
}
@@ -0,0 +1,10 @@
package games.dmg.purpursentinel;
public record MovementSample(
int tick,
long observedAtNanos,
boolean complete,
boolean cancelled,
double horizontalDistance,
ExclusionReason exclusionReason) {
}
@@ -0,0 +1,51 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Objects;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
public final class PurpurSentinelPlugin extends JavaPlugin {
private MovementMonitor monitor;
private BukkitMovementListener listener;
private BukkitTask tickTask;
@Override public void onEnable() {
try {
Files.createDirectories(getDataFolder().toPath());
var configPath = getDataFolder().toPath().resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(configPath);
var settings = SettingsService.load(new AtomicYamlSettingsStore(configPath));
monitor = new MovementMonitor(new MovementDetector());
listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime);
var command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command");
var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime);
command.setExecutor(handler);
command.setTabCompleter(handler);
getServer().getPluginManager().registerEvents(listener, this);
var alerts = new SentinelAlerts(getServer());
tickTask = getServer().getScheduler().runTaskTimer(this, () -> monitor
.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send), 1L, 1L);
} catch (IOException | IllegalArgumentException exception) {
getLogger().severe("Sentinel configuration is invalid or unavailable; observation was not activated: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
}
}
@Override public void onDisable() {
if (tickTask != null) {
tickTask.cancel();
tickTask = null;
}
if (listener != null) {
HandlerList.unregisterAll(listener);
listener = null;
}
if (monitor != null) {
monitor.clear();
monitor = null;
}
}
}
@@ -0,0 +1,23 @@
package games.dmg.purpursentinel;
import java.util.Locale;
import org.bukkit.Server;
final class SentinelAlerts {
private final Server server;
SentinelAlerts(Server server) {
this.server = server;
}
void send(AlertDecision alert) {
var subject = server.getPlayer(alert.playerId());
if (subject == null || !subject.isOnline()) return;
String message = String.format(Locale.ROOT,
"[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName());
server.getOnlinePlayers().stream()
.filter(recipient -> recipient.hasPermission("sentinel.alerts"))
.forEach(recipient -> recipient.sendMessage(message));
}
}
@@ -0,0 +1,137 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.function.LongSupplier;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
public final class SentinelCommand implements CommandExecutor, TabCompleter {
private final Server server;
private final MovementMonitor monitor;
private final SettingsService settings;
private final LongSupplier nanoTime;
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings, LongSupplier nanoTime) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
}
@Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (args.length == 0) {
sender.sendMessage("Usage: /sentinel <inspect|config> ...");
return true;
}
if (args[0].equalsIgnoreCase("inspect")) return inspect(sender, args);
if (args[0].equalsIgnoreCase("config")) return configure(sender, args);
sender.sendMessage("Unsupported Sentinel route: " + args[0] + '.');
return true;
}
@Override public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (args.length == 1) {
var routes = new ArrayList<String>();
if (sender.hasPermission("sentinel.config")) routes.add("config");
if (sender.hasPermission("sentinel.inspect")) routes.add("inspect");
return filter(routes, args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("inspect") && sender.hasPermission("sentinel.inspect")) {
return filter(server.getOnlinePlayers().stream().map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER).toList(), args[1]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("config") && sender.hasPermission("sentinel.config")) {
return filter(java.util.Arrays.stream(SettingKey.values()).map(SettingKey::key).toList(), args[1]);
}
if (args.length == 3 && args[0].equalsIgnoreCase("config") && sender.hasPermission("sentinel.config")) {
var key = SettingKey.find(args[1]);
if (key.isEmpty()) return List.of();
var values = new LinkedHashSet<String>();
values.add(key.get().minimum());
values.add(settings.value(key.get()));
values.add(key.get().maximum());
return filter(new ArrayList<>(values), args[2]);
}
return List.of();
}
private boolean inspect(CommandSender sender, String[] args) {
if (!sender.hasPermission("sentinel.inspect")) {
sender.sendMessage("You do not have permission to inspect Sentinel evidence.");
return true;
}
if (args.length != 2) {
sender.sendMessage("Usage: /sentinel inspect <player>");
return true;
}
var player = server.getOnlinePlayers().stream()
.filter(candidate -> candidate.getName().equalsIgnoreCase(args[1]))
.findFirst();
if (player.isEmpty()) {
if (server.getOfflinePlayerIfCached(args[1]) != null) {
sender.sendMessage(args[1] + " is offline; Sentinel retains no offline evidence.");
} else {
sender.sendMessage("Unknown player: " + args[1] + '.');
}
return true;
}
var snapshot = monitor.inspect(player.get().getUniqueId(), nanoTime.getAsLong());
if (snapshot.isEmpty()) {
sender.sendMessage("No current evidence for " + player.get().getName() + "; none was recorded or it expired.");
return true;
}
var evidence = snapshot.get();
sender.sendMessage(String.format(Locale.ROOT,
"%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.",
player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(),
evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
return true;
}
private boolean configure(CommandSender sender, String[] args) {
if (!sender.hasPermission("sentinel.config")) {
sender.sendMessage("You do not have permission to configure Sentinel.");
return true;
}
if (args.length < 2 || args.length > 3) {
sender.sendMessage("Usage: /sentinel config <key> [value]");
return true;
}
var key = SettingKey.find(args[1]);
if (key.isEmpty()) {
sender.sendMessage("Unknown Sentinel configuration key: " + args[1] + '.');
return true;
}
if (args.length == 2) {
sender.sendMessage(key.get().key() + " is " + settings.value(key.get()) + '.');
return true;
}
try {
String value = settings.update(key.get(), args[2]);
sender.sendMessage(key.get().key() + " updated to " + value + '.');
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
sender.sendMessage("Could not save Sentinel configuration; the prior value remains active.");
}
return true;
}
private static List<String> filter(List<String> options, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return options.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized))
.sorted(Comparator.comparing(value -> value.toLowerCase(Locale.ROOT))).toList();
}
}
@@ -0,0 +1,34 @@
package games.dmg.purpursentinel;
public record SentinelSettings(
double horizontalSpeedLimit,
int breachesToAlert,
int evidenceWindowSeconds,
int alertCooldownSeconds,
double minimumTps,
int exemptionGraceTicks) {
public SentinelSettings {
requireFiniteRange(horizontalSpeedLimit, 0.40, 5.00, "horizontal-speed-limit");
requireRange(breachesToAlert, 3, 40, "breaches-to-alert");
requireRange(evidenceWindowSeconds, 2, 60, "evidence-window-seconds");
requireRange(alertCooldownSeconds, 5, 3600, "alert-cooldown-seconds");
requireFiniteRange(minimumTps, 10.0, 20.0, "minimum-tps");
requireRange(exemptionGraceTicks, 1, 200, "exemption-grace-ticks");
}
public static SentinelSettings defaults() {
return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20);
}
private static void requireRange(int value, int minimum, int maximum, String key) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(key + " must be between " + minimum + " and " + maximum + '.');
}
}
private static void requireFiniteRange(double value, double minimum, double maximum, String key) {
if (!Double.isFinite(value) || value < minimum || value > maximum) {
throw new IllegalArgumentException(key + " must be between " + minimum + " and " + maximum + '.');
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.purpursentinel;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
public enum SettingKey {
HORIZONTAL_SPEED_LIMIT("horizontal-speed-limit", "0.40", "5.00", false),
BREACHES_TO_ALERT("breaches-to-alert", "3", "40", true),
EVIDENCE_WINDOW_SECONDS("evidence-window-seconds", "2", "60", true),
ALERT_COOLDOWN_SECONDS("alert-cooldown-seconds", "5", "3600", true),
MINIMUM_TPS("minimum-tps", "10.0", "20.0", false),
EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true);
private final String key;
private final String minimum;
private final String maximum;
private final boolean integer;
SettingKey(String key, String minimum, String maximum, boolean integer) {
this.key = key;
this.minimum = minimum;
this.maximum = maximum;
this.integer = integer;
}
public String key() { return key; }
public String minimum() { return minimum; }
public String maximum() { return maximum; }
public boolean integer() { return integer; }
public static Optional<SettingKey> find(String value) {
return Arrays.stream(values()).filter(key -> key.key.equals(value.toLowerCase(Locale.ROOT))).findFirst();
}
}
@@ -0,0 +1,16 @@
package games.dmg.purpursentinel;
import java.util.LinkedHashMap;
import java.util.Map;
public record SettingsDocument(Map<String, Object> values) {
public SettingsDocument {
values = new LinkedHashMap<>(values);
}
public SettingsDocument with(SettingKey key, Object value) {
var copy = new LinkedHashMap<>(values);
copy.put(key.key(), value);
return new SettingsDocument(copy);
}
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
import java.io.IOException;
public interface SettingsPersistence {
SettingsDocument load() throws IOException;
void save(SettingsDocument document) throws IOException;
}
@@ -0,0 +1,103 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public final class SettingsService {
private final SettingsPersistence persistence;
private final AtomicReference<SentinelSettings> current;
private SettingsService(SettingsPersistence persistence, SentinelSettings settings) {
this.persistence = persistence;
current = new AtomicReference<>(settings);
}
public static SettingsService load(SettingsPersistence persistence) throws IOException {
return new SettingsService(persistence, parse(persistence.load().values()));
}
public SentinelSettings current() {
return current.get();
}
public synchronized String update(SettingKey key, String rawValue) throws IOException {
Object parsedValue = parseValue(key, rawValue);
var latest = persistence.load();
var nextDocument = latest.with(key, parsedValue);
var nextSettings = parse(nextDocument.values());
persistence.save(nextDocument);
current.set(nextSettings);
return format(key, nextSettings);
}
public String value(SettingKey key) {
return format(key, current());
}
private static SentinelSettings parse(Map<String, Object> values) {
return new SentinelSettings(
decimal(values, SettingKey.HORIZONTAL_SPEED_LIMIT),
integer(values, SettingKey.BREACHES_TO_ALERT),
integer(values, SettingKey.EVIDENCE_WINDOW_SECONDS),
integer(values, SettingKey.ALERT_COOLDOWN_SECONDS),
decimal(values, SettingKey.MINIMUM_TPS),
integer(values, SettingKey.EXEMPTION_GRACE_TICKS));
}
private static Object parseValue(SettingKey key, String value) {
try {
if (key.integer()) {
if (!value.matches("-?(0|[1-9][0-9]*)")) throw new NumberFormatException();
return Integer.parseInt(value);
}
double parsed = Double.parseDouble(value);
if (!Double.isFinite(parsed)) throw new NumberFormatException();
return parsed;
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("Invalid value for " + key.key() + '.', exception);
}
}
private static int integer(Map<String, Object> values, SettingKey key) {
Object value = required(values, key);
if (!(value instanceof Number number)) throw new IllegalArgumentException(key.key() + " must be an integer.");
double decimal = number.doubleValue();
if (!Double.isFinite(decimal) || decimal != Math.rint(decimal)) {
throw new IllegalArgumentException(key.key() + " must be an integer.");
}
return Math.toIntExact(number.longValue());
}
private static double decimal(Map<String, Object> values, SettingKey key) {
Object value = required(values, key);
if (!(value instanceof Number number) || !Double.isFinite(number.doubleValue())) {
throw new IllegalArgumentException(key.key() + " must be a finite number.");
}
return number.doubleValue();
}
private static Object required(Map<String, Object> values, SettingKey key) {
Object value = values.get(key.key());
if (value == null) throw new IllegalArgumentException("Missing " + key.key() + '.');
return value;
}
private static String format(SettingKey key, SentinelSettings settings) {
return switch (key) {
case HORIZONTAL_SPEED_LIMIT -> decimal(settings.horizontalSpeedLimit(), 2);
case BREACHES_TO_ALERT -> Integer.toString(settings.breachesToAlert());
case EVIDENCE_WINDOW_SECONDS -> Integer.toString(settings.evidenceWindowSeconds());
case ALERT_COOLDOWN_SECONDS -> Integer.toString(settings.alertCooldownSeconds());
case MINIMUM_TPS -> decimal(settings.minimumTps(), 1);
case EXEMPTION_GRACE_TICKS -> Integer.toString(settings.exemptionGraceTicks());
};
}
private static String decimal(double value, int minimumScale) {
var decimal = BigDecimal.valueOf(value).stripTrailingZeros();
if (decimal.scale() < minimumScale) decimal = decimal.setScale(minimumScale);
return decimal.toPlainString();
}
}
+6
View File
@@ -0,0 +1,6 @@
horizontal-speed-limit: 0.80
breaches-to-alert: 6
evidence-window-seconds: 10
alert-cooldown-seconds: 60
minimum-tps: 18.0
exemption-grace-ticks: 20
+19
View File
@@ -0,0 +1,19 @@
name: PurpurSentinel
version: '${version}'
main: games.dmg.purpursentinel.PurpurSentinelPlugin
api-version: '26.3'
description: Passive and explainable movement-pattern observation for operators.
commands:
sentinel:
description: Inspect movement evidence or configure Sentinel.
usage: /sentinel <inspect|config> ...
permissions:
sentinel.alerts:
description: Receive passive movement evidence alerts.
default: op
sentinel.inspect:
description: Inspect current movement evidence for an online player.
default: op
sentinel.config:
description: Read and update Sentinel configuration.
default: op
@@ -0,0 +1,63 @@
package games.dmg.purpursentinel;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private static boolean initialized;
private NativeRuntime() {}
static synchronized void bootstrap() throws Exception {
// Paper registries are process-global and reject loading a second time.
if (initialized) return;
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
initialized = true;
}
}
@@ -0,0 +1,147 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.papermc.paper.plugin.manager.NativeEvents;
import java.io.File;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
import org.bukkit.entity.Pose;
import org.bukkit.event.HandlerList;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PluginLifecycleTest {
@TempDir File directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test @SuppressWarnings({"try", "unchecked", "rawtypes"})
void packagedMetadataEnableNativeDispatchCommandsTickAndDisableFormOneConnectedPath() throws Exception {
var server = mock(Server.class);
when(server.isPrimaryThread()).thenReturn(true);
when(server.getLogger()).thenReturn(java.util.logging.Logger.getLogger("SentinelServerTest"));
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var currentTick = new java.util.concurrent.atomic.AtomicInteger(1);
when(server.getCurrentTick()).thenAnswer(call -> currentTick.get());
var scheduler = mock(BukkitScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
var events = new NativeEvents(server);
var plugin = mock(PurpurSentinelPlugin.class);
doCallRealMethod().when(plugin).onEnable();
doCallRealMethod().when(plugin).onDisable();
when(plugin.getServer()).thenReturn(server);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("SentinelTest"));
when(plugin.getDataFolder()).thenReturn(directory);
try (var stream = getClass().getResourceAsStream("/plugin.yml")) {
var description = new PluginDescriptionFile(stream);
assertEquals("PurpurSentinel", description.getName());
assertEquals("26.3", description.getAPIVersion());
assertEquals(3, description.getPermissions().size());
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
}
var constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
constructor.setAccessible(true);
var command = constructor.newInstance("sentinel", plugin);
when(plugin.getCommand("sentinel")).thenReturn(command);
doAnswer(call -> { events.register(call.getArgument(0), plugin); return null; })
.when(manager).registerEvents(any(), eq(plugin));
var tick = new AtomicReference<Runnable>();
var task = mock(BukkitTask.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> {
tick.set(call.getArgument(1)); return task;
});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mock(org.bukkit.block.Block.class, invocation -> {
if (invocation.getMethod().getName().equals("getType")) return org.bukkit.Material.AIR;
if (invocation.getMethod().getName().equals("isLiquid")) return false;
return RETURNS_DEFAULTS.answer(invocation);
}));
var subject = mock(Player.class);
var operator = mock(Player.class);
when(subject.getUniqueId()).thenReturn(UUID.randomUUID());
when(subject.getName()).thenReturn("Alex");
when(subject.isOnline()).thenReturn(true);
when(subject.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(subject.getPose()).thenReturn(Pose.STANDING);
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
when(operator.hasPermission("sentinel.inspect")).thenReturn(true);
when(server.getPlayer(subject.getUniqueId())).thenReturn(subject);
doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers();
try (var platform = mockStatic(Bukkit.class)) {
platform.when(Bukkit::getServer).thenReturn(server);
platform.when(Bukkit::getPluginManager).thenReturn(manager);
platform.when(Bukkit::getCurrentTick).thenAnswer(call -> currentTick.get());
plugin.onEnable();
assertNotNull(tick.get());
assertInstanceOf(SentinelCommand.class, command.getExecutor());
assertSame(command.getExecutor(), command.getTabCompleter());
assertTrue(Files.readString(directory.toPath().resolve("config.yml")).contains("breaches-to-alert: 6"));
for (int sample = 1; sample <= 6; sample++) {
currentTick.set(sample);
events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0)));
currentTick.incrementAndGet();
tick.get().run();
}
verify(operator).sendMessage(argThat((String message) -> message.contains("6 movement breaches")));
assertTrue(command.execute(operator, "sentinel", new String[]{"inspect", "Alex"}));
verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: 6 breaches")));
assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"}));
Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor");
monitorField.setAccessible(true);
var monitor = (MovementMonitor) monitorField.get(plugin);
assertEquals(1, monitor.trackedPlayerCount());
plugin.onDisable();
verify(task).cancel();
assertEquals(0, monitor.trackedPlayerCount());
assertEquals(0, PlayerMoveEvent.getHandlerList().getRegisteredListeners().length);
plugin.onDisable();
} finally {
HandlerList.unregisterAll(plugin);
}
}
@Test void invalidStartupConfigurationRegistersNothing() throws Exception {
Files.writeString(directory.toPath().resolve("config.yml"), "minimum-tps: bad\n");
var server = mock(Server.class);
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
var plugin = mock(PurpurSentinelPlugin.class);
doCallRealMethod().when(plugin).onEnable();
when(plugin.getServer()).thenReturn(server);
when(plugin.getDataFolder()).thenReturn(directory);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("SentinelTest"));
plugin.onEnable();
verify(manager).disablePlugin(plugin);
verify(manager, never()).registerEvents(any(), any());
verify(server, never()).getScheduler();
}
}
@@ -0,0 +1,14 @@
package io.papermc.paper.plugin.manager;
import org.bukkit.Server;
import org.bukkit.event.Event;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;
/** Test-only access to Purpur's actual annotation registration and event dispatcher. */
public final class NativeEvents {
private final PaperEventManager events;
public NativeEvents(Server server) { events = new PaperEventManager(server); }
public void register(Listener listener, Plugin plugin) { events.registerEvents(listener, plugin); }
public void call(Event event) { events.callEvent(event); }
}
@@ -0,0 +1,96 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.entity.Pose;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerMoveEvent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class BukkitMovementListenerTest {
private static final SentinelSettings SETTINGS = new SentinelSettings(0.8, 3, 10, 60, 18, 20);
@Test void eligibleNativeMovementFeedsOneTickSampleWithoutMutatingTheEvent() {
var fixture = fixture();
var event = move(fixture.player, fixture.world, 0, 1, false);
fixture.listener.onMove(event);
fixture.monitor.finishBefore(2, 100_000_000L);
assertEquals(1, fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).orElseThrow().breachCount());
assertFalse(event.isCancelled());
assertEquals(1, event.getTo().getX());
}
@ParameterizedTest(name = "{0}") @MethodSource("directExclusions")
void directPlayerStateAndTpsExclusionsStartGrace(String name, java.util.function.Consumer<Fixture> arrange) {
var fixture = fixture();
arrange.accept(fixture);
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 2, false));
fixture.monitor.finishBefore(2, 100_000_000L);
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty(), name);
}
@Test void lifecycleVelocityQuitAndCancelledMovementArePassiveExemptions() {
var fixture = fixture();
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 2, true));
fixture.monitor.finishBefore(2, 100_000_000L);
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty());
fixture.listener.onVelocity(new org.bukkit.event.player.PlayerVelocityEvent(fixture.player, new org.bukkit.util.Vector(1, 0, 0)));
fixture.listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(fixture.player, "bye"));
assertEquals(0, fixture.monitor.trackedPlayerCount());
}
private static Stream<Arguments> directExclusions() {
return Stream.of(
Arguments.of("creative", (java.util.function.Consumer<Fixture>) f -> when(f.player.getGameMode()).thenReturn(GameMode.CREATIVE)),
Arguments.of("vehicle", (java.util.function.Consumer<Fixture>) f -> when(f.player.isInsideVehicle()).thenReturn(true)),
Arguments.of("flying", (java.util.function.Consumer<Fixture>) f -> when(f.player.isFlying()).thenReturn(true)),
Arguments.of("gliding", (java.util.function.Consumer<Fixture>) f -> when(f.player.isGliding()).thenReturn(true)),
Arguments.of("swimming", (java.util.function.Consumer<Fixture>) f -> when(f.player.isSwimming()).thenReturn(true)),
Arguments.of("crawling", (java.util.function.Consumer<Fixture>) f -> when(f.player.getPose()).thenReturn(Pose.SWIMMING)),
Arguments.of("climbing", (java.util.function.Consumer<Fixture>) f -> when(f.player.isClimbing()).thenReturn(true)),
Arguments.of("riptide", (java.util.function.Consumer<Fixture>) f -> when(f.player.isRiptiding()).thenReturn(true)),
Arguments.of("low tps", (java.util.function.Consumer<Fixture>) f -> when(f.server.getTPS()).thenReturn(new double[]{17.9, 20, 20})));
}
private static Fixture fixture() {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mock(org.bukkit.block.Block.class, invocation -> {
if (invocation.getMethod().getName().equals("getType")) return org.bukkit.Material.AIR;
if (invocation.getMethod().getName().equals("isLiquid")) return false;
return RETURNS_DEFAULTS.answer(invocation);
}));
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.getPose()).thenReturn(Pose.STANDING);
var monitor = new MovementMonitor(new MovementDetector());
var clock = new AtomicLong(50_000_000L);
var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, (from, to) -> true);
return new Fixture(server, world, player, monitor, listener);
}
private static PlayerMoveEvent move(Player player, World world, double from, double to, boolean cancelled) {
var event = new PlayerMoveEvent(player, new Location(world, from, 64, 0), new Location(world, to, 64, 0));
event.setCancelled(cancelled);
return event;
}
private record Fixture(Server server, World world, Player player, MovementMonitor monitor, BukkitMovementListener listener) {}
}
@@ -0,0 +1,45 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class DistributionTest {
@Test void shipsOnlyTheVersionedJava25PluginWithDeclaredCommandsPermissionsAndConfiguration() throws Exception {
var path = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-sentinel-" + version + ".jar", path.getFileName().toString());
try (var jar = new JarFile(path.toFile())) {
Map<String, Object> metadata = new Yaml().load(jar.getInputStream(jar.getEntry("plugin.yml")));
assertEquals("PurpurSentinel", metadata.get("name"));
assertEquals(version, metadata.get("version"));
assertEquals("games.dmg.purpursentinel.PurpurSentinelPlugin", metadata.get("main"));
assertEquals("26.3", metadata.get("api-version"));
assertEquals(1, ((Map<?, ?>) metadata.get("commands")).size());
var permissions = (Map<?, ?>) metadata.get("permissions");
assertEquals(java.util.Set.of("sentinel.alerts", "sentinel.inspect", "sentinel.config"), permissions.keySet());
for (Object value : permissions.values()) assertEquals("op", ((Map<?, ?>) value).get("default"));
assertNull(metadata.get("depend"));
assertNull(metadata.get("softdepend"));
assertNotNull(jar.getEntry("config.yml"));
assertTrue(new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes())
.contains("horizontal-speed-limit: 0.80"));
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
assertNotNull(entry);
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
assertEquals(0xcafebabe, bytes.readInt());
bytes.readUnsignedShort();
assertEquals(69, bytes.readUnsignedShort());
}
assertTrue(jar.stream().filter(candidate -> candidate.getName().endsWith(".class"))
.allMatch(candidate -> candidate.getName().startsWith("games/dmg/purpursentinel/")
&& !candidate.getName().endsWith("Test.class")));
assertTrue(jar.stream().noneMatch(candidate -> candidate.getName().contains("nativeTest")));
}
}
}
@@ -0,0 +1,106 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class MovementDetectorTest {
private static final SentinelSettings SETTINGS = new SentinelSettings(0.80, 3, 10, 60, 18.0, 20);
@Test void repeatedEligibleTicksAlertOnceAndExposeBoundedEvidence() {
var detector = new MovementDetector();
var player = UUID.randomUUID();
assertTrue(detector.observe(player, sample(1, 0.90), SETTINGS).isEmpty());
assertTrue(detector.observe(player, sample(2, 0.95), SETTINGS).isEmpty());
var alert = detector.observe(player, sample(3, 1.00), SETTINGS).orElseThrow();
assertEquals(player, alert.playerId());
assertEquals(3, alert.breachCount());
assertEquals(1.00, alert.peakSpeed(), 1e-9);
assertEquals(0.80, alert.capturedLimit(), 1e-9);
var inspection = detector.inspect(player, 150_000_000L).orElseThrow();
assertEquals(3, inspection.breachCount());
assertEquals(1.00, inspection.peakSpeed(), 1e-9);
assertEquals(0.80, inspection.capturedLimit(), 1e-9);
assertTrue(detector.observe(player, sample(4, 1.10), SETTINGS).isEmpty(), "cooldown suppresses repeats");
}
@Test void isolatedLegalAndExpiredBreachesNeverAlert() {
var detector = new MovementDetector();
var player = UUID.randomUUID();
assertTrue(detector.observe(player, sample(1, 0.40), SETTINGS).isEmpty());
assertTrue(detector.observe(player, sample(2, 0.81), SETTINGS).isEmpty());
assertTrue(detector.observe(player, sample(203, 0.82), SETTINGS).isEmpty());
assertEquals(1, detector.inspect(player, 10_100_000_000L).orElseThrow().breachCount());
}
@Test void exclusionsStartInclusiveGraceAndNeverCreateEvidence() {
for (var reason : ExclusionReason.values()) {
if (reason == ExclusionReason.NONE) continue;
var detector = new MovementDetector();
var player = UUID.randomUUID();
detector.observe(player, new MovementSample(1, 50_000_000L, true,
reason == ExclusionReason.CANCELLED, 2.0, reason), SETTINGS);
for (int tick = 2; tick <= 21; tick++) {
assertTrue(detector.observe(player, sample(tick, 2.0), SETTINGS).isEmpty(), reason.name());
}
assertTrue(detector.inspect(player, 1_050_000_000L).isEmpty());
assertTrue(detector.observe(player, sample(22, 2.0), SETTINGS).isEmpty());
assertEquals(1, detector.evidenceCount(player));
}
}
@Test void cleanupRetainsExemptionGraceUntilItsFinalTick() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
monitor.exempt(player, 10, 500_000_000L, ExclusionReason.VELOCITY, SETTINGS);
monitor.finishBefore(11, 550_000_000L);
assertEquals(1, monitor.trackedPlayerCount());
monitor.finishBefore(30, 1_500_000_000L);
assertEquals(1, monitor.trackedPlayerCount());
monitor.record(player, new MovementFrame(30, 1_500_000_000L, world, world,
0, 0, 2, 0, ExclusionReason.NONE), SETTINGS);
monitor.finishBefore(31, 1_550_000_000L);
assertTrue(monitor.inspect(player, 1_550_000_000L).isEmpty());
assertEquals(0, monitor.trackedPlayerCount());
}
@Test void evidenceIsBoundedAndLifecycleCleanupIsExact() {
var detector = new MovementDetector();
var player = UUID.randomUUID();
var longWindow = new SentinelSettings(0.40, 40, 60, 60, 18.0, 20);
for (int tick = 1; tick <= 1_250; tick++) {
detector.observe(player, new MovementSample(tick, tick * 1_000_000L, true, false, 1.0, ExclusionReason.NONE), longWindow);
}
assertEquals(MovementDetector.MAX_EVIDENCE_PER_PLAYER, detector.evidenceCount(player));
detector.forget(player);
assertEquals(0, detector.trackedPlayerCount());
detector.observe(player, sample(2_000, 1.0), SETTINGS);
detector.clear();
assertEquals(0, detector.trackedPlayerCount());
}
@Test void thresholdLimitWindowAndCooldownAreCapturedByEachFutureDecision() {
var detector = new MovementDetector();
var player = UUID.randomUUID();
var old = new SentinelSettings(0.80, 3, 60, 60, 18.0, 20);
var updated = new SentinelSettings(0.60, 4, 2, 5, 18.0, 20);
detector.observe(player, sample(1, 0.90), old);
detector.observe(player, sample(2, 0.95), old);
assertTrue(detector.observe(player, sample(3, 0.70), updated).isEmpty());
var alert = detector.observe(player, sample(4, 0.75), updated).orElseThrow();
assertEquals(4, alert.breachCount());
assertEquals(0.80, alert.capturedLimit(), 1e-9, "peak evidence keeps its captured limit");
assertTrue(detector.observe(player, sample(50, 0.90), updated).isEmpty());
assertTrue(detector.observe(player, new MovementSample(105, 5_250_000_000L, true, false, 0.90, ExclusionReason.NONE), updated).isPresent());
}
private static MovementSample sample(int tick, double distance) {
return new MovementSample(tick, tick * 50_000_000L, true, false, distance, ExclusionReason.NONE);
}
}
@@ -0,0 +1,56 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class MovementMonitorTest {
private static final UUID WORLD = UUID.randomUUID();
private static final SentinelSettings SETTINGS = new SentinelSettings(0.80, 3, 10, 60, 18.0, 20);
@Test void oneSamplePerTickUsesMaximumAnchorDistanceInsteadOfEventCountOrSum() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
monitor.record(player, frame(1, 0, 0, 0.4, 0), SETTINGS);
monitor.record(player, frame(1, 0.4, 0, 0.9, 0), SETTINGS);
monitor.record(player, frame(1, 0.4, 0, 0.9, 0), SETTINGS); // duplicate event
monitor.finishBefore(2, 100_000_000L);
var snapshot = monitor.inspect(player, 100_000_000L).orElseThrow();
assertEquals(1, snapshot.breachCount());
assertEquals(0.9, snapshot.peakSpeed(), 1e-9);
}
@Test void outAndBackRetainsFurthestPointWhileBrokenChainsExcludeWholeTick() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
monitor.record(player, frame(1, 0, 0, 1.0, 0), SETTINGS);
monitor.record(player, frame(1, 1.0, 0, 0.1, 0), SETTINGS);
monitor.finishBefore(2, 100_000_000L);
assertEquals(1.0, monitor.inspect(player, 100_000_000L).orElseThrow().peakSpeed(), 1e-9);
monitor.record(player, frame(2, 0.1, 0, 1.0, 0), SETTINGS);
monitor.record(player, frame(2, 3.0, 0, 4.0, 0), SETTINGS);
monitor.finishBefore(3, 150_000_000L);
assertEquals(1, monitor.inspect(player, 150_000_000L).orElseThrow().breachCount());
}
@Test void excludedOrCrossWorldEventInvalidatesTheWholeTick() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
monitor.record(player, frame(1, 0, 0, 1.0, 0), SETTINGS);
monitor.record(player, frame(1, 1.0, 0, 2.0, 0).withReason(ExclusionReason.ENVIRONMENT), SETTINGS);
assertEquals(List.of(), monitor.finishBefore(2, 100_000_000L));
assertTrue(monitor.inspect(player, 100_000_000L).isEmpty());
monitor.record(player, new MovementFrame(30, 1_500_000_000L, WORLD, UUID.randomUUID(), 0, 0, 1, 0, ExclusionReason.NONE), SETTINGS);
monitor.finishBefore(31, 1_550_000_000L);
assertTrue(monitor.inspect(player, 1_550_000_000L).isEmpty());
}
private static MovementFrame frame(int tick, double fromX, double fromZ, double toX, double toZ) {
return new MovementFrame(tick, tick * 50_000_000L, WORLD, WORLD, fromX, fromZ, toX, toZ, ExclusionReason.NONE);
}
}
@@ -0,0 +1,36 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PrivacyBoundaryTest {
@TempDir Path directory;
@Test void observationAndInspectionNeverPersistPlayerIdentityOrEvidence() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
byte[] before = Files.readAllBytes(file);
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.fromString("12345678-1234-1234-1234-123456789abc");
for (int tick = 1; tick <= 6; tick++) {
var world = UUID.randomUUID();
monitor.record(player, new MovementFrame(tick, tick * 50_000_000L, world, world,
120.25, -45.5, 121.25, -45.5, ExclusionReason.NONE), service.current());
monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
}
assertTrue(monitor.inspect(player, 400_000_000L).isPresent());
assertArrayEquals(before, Files.readAllBytes(file));
assertEquals(java.util.Set.of("config.yml"), Files.list(directory)
.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet()));
String disk = Files.readString(file);
assertFalse(disk.contains(player.toString()));
assertFalse(disk.contains("120.25"));
assertFalse(disk.contains("-45.5"));
}
}
@@ -0,0 +1,32 @@
package games.dmg.purpursentinel;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class SentinelAlertsTest {
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void sendsExplainableAlertsOnlyToOnlinePermittedPlayersAndNeverConsole() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var denied = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator, denied)).when(server).getOnlinePlayers();
new SentinelAlerts(server).send(new AlertDecision(id, 6, 1.03, 0.80));
verify(operator).sendMessage(argThat((String message) -> message.contains("6 movement breaches")
&& message.contains("1.03") && message.contains("0.80") && !message.toLowerCase().contains("cheat")));
verify(denied, never()).sendMessage(anyString());
verify(server, never()).getConsoleSender();
}
}
@@ -0,0 +1,140 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.OfflinePlayer;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SentinelCommandTest {
@TempDir Path directory;
@Test void inspectRechecksPermissionAndReportsOnlyBoundedSummary() throws Exception {
var fixture = fixture();
var target = online(fixture.server, "Alex");
var observer = mock(CommandSender.class);
when(observer.hasPermission("sentinel.inspect")).thenReturn(true);
addEvidence(fixture.monitor, target.getUniqueId());
assertTrue(fixture.command.onCommand(observer, mock(Command.class), "sentinel", new String[]{"inspect", "aLeX"}));
verify(observer).sendMessage(argThat((String message) -> message.contains("3 breaches")
&& message.contains("peak 1.00") && message.contains("limit 0.80")
&& !message.matches(".*-?[0-9]+\\.[0-9]+, *-?[0-9]+\\.[0-9]+.*")));
var denied = mock(CommandSender.class);
assertTrue(fixture.command.onCommand(denied, mock(Command.class), "sentinel", new String[]{"inspect", "Alex"}));
verify(denied).sendMessage("You do not have permission to inspect Sentinel evidence.");
}
@Test void inspectDistinguishesUnknownOfflineAndNoCurrentEvidenceWithoutMutation() throws Exception {
var fixture = fixture();
var sender = permitted("sentinel.inspect");
var online = online(fixture.server, "Online");
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Online"}));
verify(sender).sendMessage(contains("No current evidence"));
var offline = mock(OfflinePlayer.class);
when(fixture.server.getOfflinePlayerIfCached("Past")).thenReturn(offline);
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Past"}));
verify(sender).sendMessage("Past is offline; Sentinel retains no offline evidence.");
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Missing"}));
verify(sender).sendMessage("Unknown player: Missing.");
verify(online, never()).teleport(any(org.bukkit.Location.class));
}
@Test void configReadsAndDurablyUpdatesBeforeFutureDetection() throws Exception {
var fixture = fixture();
var sender = permitted("sentinel.config");
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"config", "horizontal-speed-limit", "0.60"}));
assertEquals(0.60, fixture.settings.current().horizontalSpeedLimit(), 1e-9);
assertEquals(0.60, SettingsService.load(new AtomicYamlSettingsStore(fixture.file)).current().horizontalSpeedLimit(), 1e-9);
verify(sender).sendMessage("horizontal-speed-limit updated to 0.60.");
var player = UUID.randomUUID();
fixture.monitor.record(player, new MovementFrame(1, 50_000_000L, UUID.randomUUID(), UUID.randomUUID(), 0, 0, 1, 0, ExclusionReason.NONE), fixture.settings.current());
assertEquals(0, fixture.monitor.trackedPlayerCount(), "cross-world input remains excluded after config mutation");
}
@Test void registeredStyleAutocompleteIsPermissionAwarePrefixFilteredStableAndNonMutating() throws Exception {
var fixture = fixture();
online(fixture.server, "Zed");
online(fixture.server, "alice");
var both = mock(CommandSender.class);
when(both.hasPermission("sentinel.inspect")).thenReturn(true);
when(both.hasPermission("sentinel.config")).thenReturn(true);
var command = mock(Command.class);
assertEquals(List.of("config", "inspect"), fixture.command.onTabComplete(both, command, "sentinel", new String[]{""}));
assertEquals(List.of("inspect"), fixture.command.onTabComplete(both, command, "sentinel", new String[]{"IN"}));
assertEquals(List.of("alice", "Zed"), fixture.command.onTabComplete(both, command, "sentinel", new String[]{"inspect", ""}));
assertEquals(List.of("horizontal-speed-limit"), fixture.command.onTabComplete(both, command, "sentinel", new String[]{"config", "hor"}));
assertEquals(List.of("0.40", "0.80", "5.00"), fixture.command.onTabComplete(both, command, "sentinel",
new String[]{"config", "horizontal-speed-limit", ""}));
assertEquals(List.of(), fixture.command.onTabComplete(both, command, "sentinel", new String[]{"inspect", "alice", "extra"}));
assertEquals(List.of(), fixture.command.onTabComplete(mock(CommandSender.class), command, "sentinel", new String[]{""}));
}
@Test void unsupportedMissingExtraInvalidAndDeniedConfigurationNeverMutate() throws Exception {
var fixture = fixture();
var original = fixture.settings.current();
var sender = permitted("sentinel.config");
for (String[] args : List.of(new String[0], new String[]{"other"}, new String[]{"config"},
new String[]{"config", "missing"}, new String[]{"config", "breaches-to-alert", "2"},
new String[]{"config", "breaches-to-alert", "8", "extra"})) {
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel", args));
}
var denied = mock(CommandSender.class);
fixture.command.onCommand(denied, mock(Command.class), "sentinel", new String[]{"config", "breaches-to-alert", "8"});
assertEquals(original, fixture.settings.current());
}
private Fixture fixture() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var settings = SettingsService.load(new AtomicYamlSettingsStore(file));
var server = mock(Server.class);
var monitor = new MovementMonitor(new MovementDetector());
var clock = new AtomicLong(150_000_000L);
return new Fixture(file, server, settings, monitor, new SentinelCommand(server, monitor, settings, clock::get));
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Player online(Server server, String name) {
var existing = new java.util.ArrayList<Player>();
for (Player player : server.getOnlinePlayers()) existing.add(player);
var player = mock(Player.class);
when(player.getName()).thenReturn(name);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
existing.add(player);
doReturn((java.util.Collection) existing).when(server).getOnlinePlayers();
return player;
}
private static CommandSender permitted(String permission) {
var sender = mock(CommandSender.class);
when(sender.hasPermission(permission)).thenReturn(true);
return sender;
}
private static void addEvidence(MovementMonitor monitor, UUID player) {
var settings = new SentinelSettings(0.8, 3, 10, 60, 18, 20);
for (int tick = 1; tick <= 3; tick++) {
var world = UUID.randomUUID();
monitor.record(player, new MovementFrame(tick, tick * 50_000_000L, world, world, 0, 0, tick == 3 ? 1 : .9, 0, ExclusionReason.NONE), settings);
monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
}
}
private record Fixture(Path file, Server server, SettingsService settings, MovementMonitor monitor, SentinelCommand command) {}
}
@@ -0,0 +1,74 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CountDownLatch;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SettingsServiceTest {
@TempDir Path directory;
@Test void validUpdatePersistsBeforePublicationAndPreservesUnrelatedYaml() throws Exception {
var file = directory.resolve("config.yml");
Files.writeString(file, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\nevidence-window-seconds: 10\nalert-cooldown-seconds: 60\nminimum-tps: 18.0\nexemption-grace-ticks: 20\nunrelated: keep-me\n");
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals("0.60", service.update(SettingKey.HORIZONTAL_SPEED_LIMIT, "0.60"));
assertEquals(0.60, service.current().horizontalSpeedLimit(), 1e-9);
String saved = Files.readString(file);
assertTrue(saved.contains("horizontal-speed-limit: 0.6"));
assertTrue(saved.contains("unrelated: keep-me"));
}
@Test void invalidAndFailedWritesKeepPriorEffectiveAndDiskValues() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var initialBytes = Files.readAllBytes(file);
var failing = new SettingsPersistence() {
private final AtomicYamlSettingsStore delegate = new AtomicYamlSettingsStore(file);
@Override public SettingsDocument load() throws IOException { return delegate.load(); }
@Override public void save(SettingsDocument document) throws IOException { throw new IOException("fixture failure"); }
};
var service = SettingsService.load(failing);
assertThrows(IllegalArgumentException.class, () -> service.update(SettingKey.BREACHES_TO_ALERT, "2"));
assertThrows(IOException.class, () -> service.update(SettingKey.BREACHES_TO_ALERT, "8"));
assertEquals(6, service.current().breachesToAlert());
assertArrayEquals(initialBytes, Files.readAllBytes(file));
}
@Test void concurrentAcceptedUpdatesSerializeWithoutLosingEitherValue() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
var gate = new CountDownLatch(1);
var first = Thread.ofPlatform().start(() -> update(service, gate, SettingKey.BREACHES_TO_ALERT, "8"));
var second = Thread.ofPlatform().start(() -> update(service, gate, SettingKey.EVIDENCE_WINDOW_SECONDS, "12"));
gate.countDown();
first.join();
second.join();
assertEquals(8, service.current().breachesToAlert());
assertEquals(12, service.current().evidenceWindowSeconds());
var loaded = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(service.current(), loaded.current());
}
@Test void malformedStartupConfigurationFailsBeforeUse() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
Files.writeString(file, Files.readString(file).replace("minimum-tps: 18.0", "minimum-tps: NaN"));
assertThrows(IllegalArgumentException.class, () -> SettingsService.load(new AtomicYamlSettingsStore(file)));
}
private static void update(SettingsService service, CountDownLatch gate, SettingKey key, String value) {
try {
gate.await();
service.update(key, value);
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "build/release-tools",
"rootDir": "scripts",
"types": ["node"],
"lib": ["ES2022", "DOM"],
"noEmitOnError": true
},
"include": ["scripts/*.ts"]
}