diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile
index 67866974cf..8f1430d073 100644
--- a/hugegraph-server/Dockerfile
+++ b/hugegraph-server/Dockerfile
@@ -70,6 +70,9 @@ RUN sed -i "s/^restserver.url.*$/restserver.url=http:\/\/0.0.0.0:8080/g" ./conf/
COPY hugegraph-server/hugegraph-dist/docker/scripts/remote-connect.groovy ./scripts
COPY hugegraph-server/hugegraph-dist/docker/scripts/detect-storage.groovy ./scripts
COPY hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh .
+# props.awk needs no COPY: it ships in the assembly bin/ above, which is also
+# where bin/enable-auth.sh finds it. yamlscan.awk serves only the entrypoint.
+COPY hugegraph-server/hugegraph-dist/docker/yamlscan.awk .
RUN chmod 755 ./docker-entrypoint.sh
EXPOSE 8080
diff --git a/hugegraph-server/Dockerfile-hstore b/hugegraph-server/Dockerfile-hstore
index e90979563b..e18d76bd07 100644
--- a/hugegraph-server/Dockerfile-hstore
+++ b/hugegraph-server/Dockerfile-hstore
@@ -72,6 +72,9 @@ RUN cd /hugegraph-server/conf/graphs \
COPY hugegraph-server/hugegraph-dist/docker/scripts/remote-connect.groovy ./scripts
#COPY hugegraph-server/hugegraph-dist/docker/scripts/detect-storage.groovy ./scripts
COPY hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh .
+# props.awk needs no COPY: it ships in the assembly bin/ above, which is also
+# where bin/enable-auth.sh finds it. yamlscan.awk serves only the entrypoint.
+COPY hugegraph-server/hugegraph-dist/docker/yamlscan.awk .
RUN chmod 755 ./docker-entrypoint.sh
EXPOSE 8080
diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh
index 42137dc3b5..54d289bf61 100755
--- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh
+++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh
@@ -17,12 +17,28 @@
#
set -euo pipefail
+# CI runs this harness under a backend matrix: server-ci.yml exports BACKEND for
+# the rocksdb leg, which is the only leg that reaches these tests. The
+# entrypoint maps BACKEND to HG_SERVER_BACKEND and then overwrites whatever a
+# fixture writes into hugegraph.properties, so a case that decides on the
+# on-disk backend -- the escaped-hstore assertion below -- would be answered by
+# the matrix value rather than by the file it is checking, and would fail in CI
+# while passing locally. Clear the inherited backend/pd environment so the
+# harness is hermetic; cases that mean to drive the entrypoint from the
+# environment set it on their own invocation (see the hstore mapping test). The
+# production precedence (environment beats file) is left exactly as it is.
+unset BACKEND HG_SERVER_BACKEND PD_PEERS HG_SERVER_PD_PEERS
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
TEST_HOME=$(mktemp -d "${TMPDIR:-/tmp}/hugegraph-entrypoint-test.XXXXXX")
trap 'rm -rf "${TEST_HOME}"' EXIT
mkdir -p "${TEST_HOME}/bin" "${TEST_HOME}/conf/graphs" "${TEST_HOME}/docker"
cp "${SCRIPT_DIR}/docker-entrypoint.sh" "${TEST_HOME}/docker-entrypoint.sh"
+# props.awk is packaged in the release bin/; the image gets it from there, and
+# the entrypoint accepts it beside itself so this harness can stage either.
+cp "${SCRIPT_DIR}/../src/assembly/static/bin/props.awk" "${TEST_HOME}/props.awk"
+cp "${SCRIPT_DIR}/yamlscan.awk" "${TEST_HOME}/yamlscan.awk"
touch "${TEST_HOME}/docker/init_complete"
cat > "${TEST_HOME}/conf/rest-server.properties" <<'EOF'
@@ -54,6 +70,7 @@ printf 'called\n' >> ./docker/enable-auth-calls
EOF
cat > "${TEST_HOME}/bin/wait-partition.sh" <<'EOF'
#!/usr/bin/env bash
+printf 'called\n' >> ./docker/wait-partition-calls
exit 0
EOF
cat > "${TEST_HOME}/bin/wait-storage.sh" <<'EOF'
@@ -200,7 +217,7 @@ reused_complex_secret=$(sed -n 's/^auth\.token_secret=//p' \
"${TEST_HOME}/conf/rest-server.properties")
[[ "${reused_complex_secret}" == "${complex_secret}" ]]
[[ "${reused_complex_secret}" == \
- 'Strong\\Secret\ 9!0123456789abcdef' ]]
+ 'Strong\\Secret\u00209!0123456789abcdef' ]]
(
cd "${TEST_HOME}"
@@ -217,8 +234,38 @@ trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \
reused_trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \
"${TEST_HOME}/conf/rest-server.properties")
[[ "${trailing_space_secret}" == \
- 'SecretEnds\ 0123456789abcdefABCDE\ ' ]]
+ 'SecretEnds\u00200123456789abcdefABCDE\u0020' ]]
[[ "${reused_trailing_space_secret}" == "${trailing_space_secret}" ]]
+grep -Fqx 'auth.admin_pa=pa' \
+ "${TEST_HOME}/conf/rest-server.properties"
+# The secret above ends in a space, and commons-configuration trims a physical
+# line before it asks whether that line continues: the `\ ` the encoder used to
+# write survives the trim as a lone trailing backslash, which pulls the property
+# under it into the password -- that is how a file that plainly carried
+# auth.admin_pa next to it would reach the server as one long secret. \u0020
+# leaves the trimmer nothing to take.
+grep -Fqx 'auth.token_secret=SecretEnds\u00200123456789abcdefABCDE\u0020' \
+ "${TEST_HOME}/conf/rest-server.properties" || {
+ echo "a trailing space must not be written as a backslash-space" >&2
+ sed -n 's/^auth\.token_secret=/written: [&]/p' \
+ "${TEST_HOME}/conf/rest-server.properties" >&2
+ exit 1
+}
+# Round trip: the secret comes back with both spaces it started with, and the
+# property written under it is still its own property.
+props_read() {
+ PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="$1" \
+ PROPS_FILE="${TEST_HOME}/conf/rest-server.properties" \
+ awk -f "${TEST_HOME}/props.awk" /dev/null
+}
+[[ "$(props_read auth.token_secret)" == 'SecretEnds 0123456789abcdefABCDE ' ]] || {
+ echo "auth.token_secret lost its spaces: [$(props_read auth.token_secret)]" >&2
+ exit 1
+}
+[[ "$(props_read auth.admin_pa)" == "pa" ]] || {
+ echo "auth.admin_pa is not its own property any more: [$(props_read auth.admin_pa)]" >&2
+ exit 1
+}
grep -Fqx 'auth.admin_pa=pa' \
"${TEST_HOME}/conf/rest-server.properties"
@@ -226,7 +273,7 @@ grep -Fqx 'auth.admin_pa=pa' \
cd "${TEST_HOME}"
PASSWORD='Strong\Pass 9!' bash ./docker-entrypoint.sh
)
-grep -Fqx 'auth.admin_pa=Strong\\Pass\ 9!' \
+grep -Fqx 'auth.admin_pa=Strong\\Pass\u00209!' \
"${TEST_HOME}/conf/rest-server.properties"
rm -f "${TEST_HOME}/docker/init_complete"
@@ -324,4 +371,95 @@ done
)
assert_start_timeout 120
+# A mounted rest-server.properties that already carries auth.authenticator,
+# with no matching yaml mapping and no PASSWORD given, used to start without a
+# word: the parity check ran only inside the PASSWORD branch, so nothing ever
+# compared the two sides and the server came up with REST enforcing and Gremlin
+# on AllowAllAuthenticator. The check now runs on every start, and a refusal
+# has to come before anything touches the backend.
+printf '%s\n' 'host: 8182' > "${TEST_HOME}/conf/gremlin-server.yaml"
+grep -qx 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ "${TEST_HOME}/conf/rest-server.properties" ||
+ printf '%s\n' \
+ 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ >> "${TEST_HOME}/conf/rest-server.properties"
+rm -f "${TEST_HOME}/docker/init_complete"
+before_calls="$(wc -l < "${TEST_HOME}/docker/init-store-calls")"
+before_auth="$(wc -l < "${TEST_HOME}/docker/enable-auth-calls")"
+status=0
+(
+ cd "${TEST_HOME}"
+ bash ./docker-entrypoint.sh
+) || status=$?
+if (( status == 0 )); then
+ echo "entrypoint must refuse a mounted REST-only authenticator with no PASSWORD" >&2
+ exit 1
+fi
+if [[ "$(wc -l < "${TEST_HOME}/docker/init-store-calls")" != "${before_calls}" ]]; then
+ echo "the refusal must happen before init-store runs" >&2
+ exit 1
+fi
+if [[ "$(wc -l < "${TEST_HOME}/docker/enable-auth-calls")" != "${before_auth}" ]]; then
+ echo "a refused start must not run enable-auth.sh" >&2
+ exit 1
+fi
+
+# The same start is accepted once both sides agree, so the check above is a
+# parity decision and not a blanket refusal to run without PASSWORD.
+printf '%s\n' \
+ 'authentication: {' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator,' \
+ ' config: {tokens: conf/rest-server.properties}' \
+ '}' > "${TEST_HOME}/conf/gremlin-server.yaml"
+rm -f "${TEST_HOME}/docker/init_complete"
+(
+ cd "${TEST_HOME}"
+ bash ./docker-entrypoint.sh
+)
+
+# ── The stabilization check follows the backend the JVM actually loaded ──
+# ACTUAL_BACKEND is compared against a literal, so it has to be the decoded
+# value. A mounted hugegraph.properties may spell the word with a unicode
+# escape for the s, which java.util.Properties hands the server as hstore;
+# reading the on-disk escaping instead compared something else to hstore,
+# skipped wait-partition.sh, and let startup continue before the partitions
+# were assigned. bs is the backslash, taken from its code point rather than
+# written here: printf '%c' 92 hands back the digit 9, which would have built a
+# fixture holding a different word than the one being decoded.
+bs=$(awk 'BEGIN { printf "%c", 92 }')
+if [[ "${#bs}" != 1 || "$(printf '%d' "'${bs}")" != 92 ]]; then
+ echo "this host did not yield a backslash for code point 92" >&2
+ exit 1
+fi
+touch "${TEST_HOME}/docker/init_complete"
+rm -f "${TEST_HOME}/docker/wait-partition-calls"
+printf '%s\n' "backend=h${bs}u0073tore" 'pd.peers=pd:8686' \
+ > "${TEST_HOME}/conf/graphs/hugegraph.properties"
+if [[ "$(head -n 1 "${TEST_HOME}/conf/graphs/hugegraph.properties")" != \
+ "backend=h${bs}u0073tore" ]]; then
+ echo "the fixture has to hold the escaped bytes, not the decoded word" >&2
+ exit 1
+fi
+(
+ cd "${TEST_HOME}"
+ bash ./docker-entrypoint.sh
+)
+if [[ ! -s "${TEST_HOME}/docker/wait-partition-calls" ]]; then
+ echo "an escaped hstore backend must still reach wait-partition.sh" >&2
+ exit 1
+fi
+# The other half: this is a read that follows the server, not a switch that
+# simply always waits.
+rm -f "${TEST_HOME}/docker/wait-partition-calls"
+printf '%s\n' 'backend=rocksdb' 'pd.peers=pd:8686' \
+ > "${TEST_HOME}/conf/graphs/hugegraph.properties"
+(
+ cd "${TEST_HOME}"
+ bash ./docker-entrypoint.sh
+)
+if [[ -e "${TEST_HOME}/docker/wait-partition-calls" ]]; then
+ echo "wait-partition.sh ran for a rocksdb backend" >&2
+ exit 1
+fi
+
echo "PASS: Docker entrypoint configures HStore discovery and authentication"
diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
index b5ba2de34f..1005a2e608 100755
--- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
+++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
@@ -26,6 +26,45 @@ mkdir -p "${DOCKER_FOLDER}"
log() { echo "[hugegraph-server-entrypoint] $*"; }
+# Property reading/writing goes through props.awk, which implements the
+# java.util.Properties grammar HugeConfig applies (escapes, `:`/whitespace
+# separators, CR/CRLF/LF line terminators, continuations, first-definition-wins
+# duplicates). grep/sed rewrites disagree with it on mounted or upgraded
+# configs, silently producing two definitions of one key. Values move through
+# environment variables rather than argv so a PASSWORD never shows up in `ps`
+# output.
+#
+# props.awk lives in the packaged bin/ directory because bin/enable-auth.sh
+# reads properties with it too, and that assembly fileSet is what both the
+# release tarball and this image are built from. Beside the entrypoint is only
+# where the source tree and the tests put it.
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+props_from_env="${PROPS_AWK:-}"
+yaml_from_env="${YAMLSCAN_AWK:-}"
+PROPS_AWK=""
+for candidate in "${props_from_env}" "${HERE}/props.awk" "${HERE}/bin/props.awk"; do
+ if [[ -n "${candidate}" && -f "${candidate}" ]]; then
+ PROPS_AWK="${candidate}"
+ break
+ fi
+done
+if [[ -z "${PROPS_AWK}" ]]; then
+ log "ERROR: props.awk not found beside the entrypoint or in bin/"
+ exit 1
+fi
+
+YAMLSCAN=""
+for candidate in "${yaml_from_env}" "${HERE}/yamlscan.awk"; do
+ if [[ -n "${candidate}" && -f "${candidate}" ]]; then
+ YAMLSCAN="${candidate}"
+ break
+ fi
+done
+if [[ -z "${YAMLSCAN}" ]]; then
+ log "ERROR: yamlscan.awk not found beside the entrypoint"
+ exit 1
+fi
+
encode_prop_value() {
local value="$1" encoded="" char
local i
@@ -35,7 +74,13 @@ encode_prop_value() {
char="${value:i:1}"
case "${char}" in
"\\") encoded+="\\\\" ;;
- " ") encoded+="\\ " ;;
+ # \u0020 and not `\ `: both readers turn it back into a space, but
+ # a line right-trimmed before the continuation check -- which is how
+ # commons-configuration reads it -- leaves the backslash of `\ `
+ # behind at the end of the line, and that swallows the property
+ # written under it. A secret ending in a space used to move
+ # auth.authenticator inside the password value.
+ " ") encoded+="\\u0020" ;;
$'\t') encoded+="\\t" ;;
$'\n') encoded+="\\n" ;;
$'\r') encoded+="\\r" ;;
@@ -48,18 +93,10 @@ encode_prop_value() {
set_prop_encoded() {
local key="$1" encoded_val="$2" file="$3"
- local esc_key esc_val key_re
-
- esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g')
- esc_val=$(printf '%s' "$encoded_val" | sed -e 's/[&|\\~]/\\&/g')
- key_re="^[[:space:]]*${esc_key}([[:space:]]*[:=]|[[:space:]]+|[[:space:]]*$)"
- if grep -qE "${key_re}" "${file}"; then
- sed -ri "0,/${key_re}/!{/${key_re}/d;}" "${file}"
- sed -ri "0,/${key_re}/s~${key_re}.*~${key}=${esc_val}~" "${file}"
- else
- printf '%s=%s\n' "$key" "$encoded_val" >> "${file}"
- fi
+ PROPS_MODE=set PROPS_KEY="${key}" \
+ PROPS_VALUE_ENCODED="${encoded_val}" PROPS_FILE="${file}" \
+ awk -f "${PROPS_AWK}" /dev/null
}
set_prop() {
@@ -70,12 +107,82 @@ set_prop() {
get_prop_encoded() {
local key="$1" file="$2"
- local esc_key
- esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g')
- sed -nE \
- "s~^[[:space:]]*${esc_key}([[:space:]]*[:=][[:space:]]*|[[:space:]]+)(.*)$~\\2~p" \
- "${file}" | head -n 1
+ PROPS_MODE=get PROPS_KEY="${key}" PROPS_FILE="${file}" \
+ awk -f "${PROPS_AWK}" /dev/null
+}
+
+# The value as java.util.Properties hands it to the server, escapes resolved.
+# Compare against this, not the on-disk bytes: `backend=h\u0073tore` is a legal
+# spelling of hstore that the JVM reads as hstore and a string compare against
+# the raw text does not.
+get_prop_decoded() {
+ local key="$1" file="$2"
+
+ PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="${key}" PROPS_FILE="${file}" \
+ awk -f "${PROPS_AWK}" /dev/null
+}
+
+# What the top-level authentication mapping of gremlin-server.yaml says about
+# authentication, as one of three states: none, named, nameless.
+#
+# The question and its answer live in yamlscan.awk, which reads the mapping the
+# way snakeyaml presents it to the server: only a column-0 `authentication`
+# mapping counts, only its direct `authenticator` child names a class, comment
+# text never counts as content, and a nested `config.authenticator` belongs to
+# the config map rather than to the server. Those distinctions are the whole
+# decision -- an earlier grep-shaped version of this function reported `named`
+# for `authentication: {} # authenticator: X` and for a class nested under
+# `config:`, which passed the REST/Gremlin parity check while Gremlin was
+# running on AllowAllAuthenticator.
+yaml_auth_state() {
+ local yaml="./conf/gremlin-server.yaml"
+
+ [[ -f "${yaml}" ]] || { echo "none"; return 0; }
+ awk -f "${YAMLSCAN}" "${yaml}"
+}
+
+# Authentication has to be configured on both sides or on neither. A mounted
+# config carrying only one is refused rather than completed: the entrypoint
+# cannot know which class the operator means, and finishing the other side from
+# a guessed default is how Gremlin ends up on AllowAllAuthenticator while REST
+# enforces StandardAuthenticator. A mapping that names no authenticator is
+# refused by itself, because enable-auth.sh guards on the presence of that
+# mapping and would otherwise write only the REST side.
+check_auth_sides() {
+ local rest=0 yaml=0 state rest_value
+
+ state=$(yaml_auth_state)
+ if [[ "${state}" == "nameless" ]]; then
+ log "ERROR: gremlin-server.yaml carries a top-level authentication" \
+ "mapping that names no authenticator; add an authenticator entry" \
+ "to it or remove the mapping, then restart."
+ return 1
+ fi
+ # A nonzero status here means the reader could not answer at all -- props.awk
+ # exits 2 rather than guess, e.g. for a file that splices another one with an
+ # commons-configuration `include`. Calling that "configured on one side"
+ # would send the operator to the wrong file, and calling it absent is the
+ # direction that lets REST start open beside a Gremlin that authenticates.
+ if ! rest_value=$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}"); then
+ log "ERROR: cannot read auth.authenticator from ${REST_SERVER_CONF};" \
+ "see the reason above, fix it, then restart."
+ return 1
+ fi
+ if [[ -n "${rest_value}" ]]; then
+ rest=1
+ fi
+ if [[ "${state}" == "named" ]]; then
+ yaml=1
+ fi
+ if (( rest == yaml )); then
+ return 0
+ fi
+ log "ERROR: authentication is configured in only one of" \
+ "rest-server.properties (auth.authenticator) and" \
+ "gremlin-server.yaml (authentication.authenticator);" \
+ "configure both or neither, then restart."
+ return 1
}
migrate_env() {
@@ -168,6 +275,14 @@ elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then
set_prop_encoded "auth.token_secret" "${AUTH_TOKEN_SECRET_ENCODED}" \
"${GRAPH_CONF}"
fi
+# Both sides have to agree whether authentication is on, whatever the reason
+# the container was started for. Running this only inside the PASSWORD branch
+# below left a mounted rest-server.properties that carried auth.authenticator
+# with no matching yaml mapping completely unvalidated: with no PASSWORD the
+# entrypoint skipped the check, enable-auth.sh never ran, and the server came
+# up with REST enforcing and Gremlin open. A refusal exits under set -e.
+check_auth_sides
+
if [[ -n "${PASSWORD:-}" ]]; then
set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}"
# This script is idempotent and must run outside the initialization guard:
@@ -243,7 +358,15 @@ fi
./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t "${SERVER_STARTUP_TIMEOUT_S}"
# Post-startup cluster stabilization check (hstore only — rocksdb has no partitions)
-ACTUAL_BACKEND=$(grep -E '^[[:space:]]*backend[[:space:]]*=' "${GRAPH_CONF}" | head -n 1 | sed 's/.*=//' | tr -d '[:space:]' || true)
+# Read through props.awk so a mounted config using the `:` or bare-whitespace
+# separator is seen at all, and first-definition-wins matches HugeConfig; the
+# grep this replaces only ever accepted `=`. Decoded, because this is compared
+# against a literal: the JVM reads `backend=h\u0073tore` as hstore while the
+# on-disk bytes are not that string, and the comparison deciding to skip
+# wait-partition.sh is how startup continued before partitions were assigned.
+# Trailing whitespace is dropped here rather than in the reader, which reports
+# the value verbatim apart from the escapes java.util.Properties resolves.
+ACTUAL_BACKEND=$(get_prop_decoded "backend" "${GRAPH_CONF}" | tr -d '[:space:]' || true)
if [[ "${ACTUAL_BACKEND}" == "hstore" ]]; then
STORE_REST="${STORE_REST:-store:8520}"
export STORE_REST
diff --git a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
index d5e11c5022..c59ad10f37 100644
--- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
+++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
@@ -23,11 +23,93 @@ entrypoint="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/docker-entrypoint.s
test_dir="$(mktemp -d)"
trap 'rm -rf "${test_dir}"' EXIT
-eval "$(awk '
- /^encode_prop_value\(\) \{/ { capture = 1 }
- capture { print }
- capture && /^\}$/ && ++function_ends == 3 { exit }
-' "${entrypoint}")"
+# Eval the property and yaml helpers one by one. The entrypoint's
+# top-level code hard-exits when props.awk is missing, so it cannot be
+# sourced directly; extracting by function name keeps this independent of
+# helper order. PROPS_AWK is recomputed below.
+for fn in encode_prop_value set_prop_encoded set_prop get_prop_encoded \
+ get_prop_decoded yaml_auth_state check_auth_sides; do
+ eval "$(awk -v fn="${fn}" '
+ index($0, fn "() {") == 1 { capture = 1 }
+ capture { print }
+ capture && /^}$/ { exit }
+ ' "${entrypoint}")"
+done
+log() { echo "[hugegraph-server-entrypoint] $*"; }
+static_bin="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)"
+docker_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PROPS_AWK="${static_bin}/props.awk"
+YAMLSCAN="${docker_dir}/yamlscan.awk"
+export PROPS_AWK YAMLSCAN
+
+# enable-auth.sh reads and writes .properties through props.awk, which the
+# release assembly packages in the same bin/ directory. A test tree that runs
+# the script therefore has to carry both, or it is not the layout it ships in.
+# yamlscan.awk goes one level up, in the install home, exactly where the
+# Dockerfile puts it: the guard in enable-auth.sh has to answer the Gremlin
+# question with the same reader check_auth_sides uses, so a tree without it
+# would be testing the tarball fallback rather than the image.
+install_enable_auth() {
+ local dir="$1"
+ mkdir -p "${dir}/bin"
+ cp "${static_bin}/enable-auth.sh" "${dir}/bin/enable-auth.sh"
+ cp "${static_bin}/props.awk" "${dir}/bin/props.awk"
+ cp "${docker_dir}/yamlscan.awk" "${dir}/yamlscan.awk"
+ chmod +x "${dir}/bin/enable-auth.sh"
+}
+
+# ── What this host can actually be asked about ─────────────────────────
+# CI runs these assertions on Ubuntu, where every one of them means what it
+# says. Developed against a Windows host, three things silently stop being
+# observations about props.awk and become observations about the platform:
+# MSYS gawk opens text files in translation mode and drops the CR of a CRLF
+# pair, chmod does not affect the mode stat reports, and a symlinked config is
+# not a symlink. Each group is therefore gated on a probe of the host, and a
+# skipped group says so out loud rather than passing quietly.
+skip() { echo "note: skipped $1 -- this host cannot exercise it; it runs under CI" >&2; }
+
+probe="${test_dir}/probe"
+
+awk_sees_crlf_cr=0
+if [[ "$(printf 'x\r\n' | awk 'NR == 1 { print length($0) }')" == "2" ]]; then
+ awk_sees_crlf_cr=1
+fi
+awk_sees_lone_cr=0
+if [[ "$(printf 'a\rb' | awk 'NR == 1 { print length($0) }')" == "3" ]]; then
+ awk_sees_lone_cr=1
+fi
+
+host_keeps_chmod=0
+printf '%s\n' x > "${probe}"
+chmod 600 "${probe}"
+[[ "$(stat -c '%a' "${probe}")" == "600" ]] && host_keeps_chmod=1
+rm -f "${probe}"
+
+# The probe above asks whether the host reports a mode back, which is a question
+# about `stat`. The read-only-config group below needs a different answer: does
+# a 0444 mode actually stop a write. The two are independent -- a Windows host
+# under MSYS keeps the write denial (redirect, append and `test -w` all honour
+# the read-only attribute) while `stat` still prints 644 -- so gating that group
+# on host_keeps_chmod would skip it where it genuinely runs, and gating it on
+# nothing would fail on a host that ignores modes. Asking the host what it does
+# is the only honest way to choose.
+host_denies_write=0
+printf '%s\n' x > "${probe}"
+chmod 444 "${probe}"
+# The redirect runs inside a group carrying the 2>/dev/null, because a failed
+# redirection is reported by the shell that opens the file, not by printf: put on
+# the command itself the redirect error still reaches stderr and the probe prints
+# a spurious failure on every host that denies the write.
+if ! { printf 'y' >> "${probe}"; } 2>/dev/null && ! test -w "${probe}"; then
+ host_denies_write=1
+fi
+chmod 644 "${probe}"
+rm -f "${probe}"
+
+host_keeps_symlink=0
+printf '%s\n' x > "${probe}-t"
+ln -s "${probe}-t" "${probe}-l" 2>/dev/null && [[ -L "${probe}-l" ]] && host_keeps_symlink=1
+rm -f "${probe}-t" "${probe}-l"
assert_replaced() {
local separator="$1"
@@ -66,3 +148,2151 @@ assert_line_count 1 \
"${duplicate_file}"
assert_line_count 1 '^init_store\.enabled=true$' "${duplicate_file}"
grep -q '^unrelated=true$' "${duplicate_file}"
+
+# An escaped key is one logical definition of that key, not a key with
+# backslashes in its name: setting the plain key must rewrite it in place
+# rather than appending a second definition whose only resolution is
+# parser-dependent (and which HugeConfig then reports as a list).
+escaped_file="${test_dir}/config-escaped-key"
+printf '%s\n' \
+ 'auth\.admin_pa=old' \
+ 'unrelated=true' > "${escaped_file}"
+set_prop "auth.admin_pa" "new" "${escaped_file}"
+assert_line_count 1 '^auth\.admin_pa=new$' "${escaped_file}"
+assert_line_count 1 '^unrelated=true$' "${escaped_file}"
+
+# A value continued onto the next line is part of the same definition:
+# setting the key must remove the continuation, not leave it behind as a
+# stray property of its own.
+continued_file="${test_dir}/config-continuation"
+printf '%s\n' \
+ 'pd.peers 127.0.0.1:8686,\' \
+ ' 127.0.0.2:8686' \
+ 'unrelated=true' > "${continued_file}"
+set_prop "pd.peers" "10.0.0.1:8686" "${continued_file}"
+assert_line_count 1 '^pd\.peers=10\.0\.0\.1:8686$' "${continued_file}"
+assert_line_count 1 '^unrelated=true$' "${continued_file}"
+[[ "$(grep -c '127\.0\.0\.2' "${continued_file}")" -eq 0 ]]
+
+# get_prop_encoded reads through the same grammar: separators, escapes,
+# continuations, and first-definition-wins duplicates.
+get_file="${test_dir}/config-get"
+printf '%s\n' \
+ '#comment' \
+ 'a\=b : colon value' \
+ 'multiline first \' \
+ ' second' \
+ 'dup : one' \
+ 'dup=two' > "${get_file}"
+[[ "$(get_prop_encoded 'a=b' "${get_file}")" == "colon value" ]]
+[[ "$(get_prop_encoded 'multiline' "${get_file}")" == "first second" ]]
+[[ "$(get_prop_encoded 'dup' "${get_file}")" == "one" ]]
+
+# Appends must still happen when the file has no definition of the key,
+# including when the only occurrences are inside comments.
+append_file="${test_dir}/config-append"
+printf '%s\n' \
+ '#init_store.enabled=false' \
+ 'unrelated=true' > "${append_file}"
+set_prop "init_store.enabled" "true" "${append_file}"
+assert_line_count 1 '^init_store\.enabled=true$' "${append_file}"
+assert_line_count 1 '^#init_store\.enabled=false$' "${append_file}"
+
+# A key indented with leading whitespace is still one definition of the
+# key: java.util.Properties ignores whitespace before a key, so an
+# indented key must be read and rewritten in place rather than duplicated.
+indented_file="${test_dir}/config-indented-key"
+printf '%s\n' \
+ ' auth.token_secret: old-secret' \
+ 'unrelated=true' > "${indented_file}"
+[[ "$(get_prop_encoded 'auth.token_secret' "${indented_file}")" == "old-secret" ]]
+set_prop_encoded 'auth.token_secret' 'new-secret' "${indented_file}"
+assert_line_count 1 'auth\.token_secret' "${indented_file}"
+assert_line_count 1 '^unrelated=true$' "${indented_file}"
+
+# yaml_auth_state reports whether the top-level authentication mapping names
+# an authenticator, without ever reading the class: quoted scalars and inline
+# comments still count as naming one, a flow mapping on the key line counts, a
+# mapping with no authenticator is "nameless", and an `authentication:` nested
+# under some other key is not the Gremlin mapping at all.
+yaml_dir="${test_dir}/yaml"
+mkdir -p "${yaml_dir}/conf"
+(
+ cd "${yaml_dir}" || exit 1
+ state_file="conf/gremlin-server.yaml"
+
+ want_state() {
+ if [[ "$1" != "$2" ]]; then
+ echo "expected yaml state '$1', got '$2'" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' 'host: 0.0.0.0' > "${state_file}"
+ want_state none "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticator: com.example.MyAuth' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticator: "com.example.MyAuth" # custom' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication: {authenticator: com.example.FlowAuth, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, config: {tokens: conf/rest-server.properties}}' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ # The nested mapping belongs to someFeature, not to the Gremlin server.
+ # Reading it as the Gremlin one would let com.example.Nested authenticate
+ # REST while Gremlin stayed on TinkerPop's AllowAllAuthenticator default.
+ printf '%s\n' \
+ 'someFeature:' \
+ ' authentication:' \
+ ' authenticator: com.example.Nested' \
+ > "${state_file}"
+ want_state none "$(yaml_auth_state)"
+
+ # An authenticator that only appears after the block ends is a sibling's.
+ printf '%s\n' \
+ 'authentication:' \
+ ' tokens: conf/rest-server.properties' \
+ 'other:' \
+ ' authenticator: com.example.Other' \
+ > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ # A blank line does not close a YAML mapping.
+ printf '%s\n' \
+ 'authentication:' \
+ ' tokens: conf/rest-server.properties' \
+ '' \
+ ' authenticator: com.example.Later' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ # A flow mapping spread over several lines closes at the root indentation.
+ # Taking that brace for a root sibling stopped the mapping one entry early,
+ # so a config naming a class answered nameless and the boot was refused.
+ printf '%s\n' \
+ 'authentication: {' \
+ ' authenticator: com.example.SpreadAuth' \
+ '}' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ # The same form with no direct authenticator still has to be nameless, which
+ # is what keeps the fix from turning the refusal into a blanket pass.
+ printf '%s\n' \
+ 'authentication: {' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ '}' \
+ > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ # A double quoted key resolves its escapes before it is a key, so this is
+ # the authentication mapping. Comparing the raw bytes called it absent,
+ # which is the answer that lets REST start open beside a Gremlin that
+ # authenticates. The value is spelled out in hex below to keep the backslash.
+ printf '%s\n' \
+ '"authentic\u0061tion":' \
+ ' authenticator: com.example.EscapedAuth' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ # Same for the direct child key.
+ printf '%s\n' \
+ 'authentication:' \
+ ' "authentic\u0061tor": com.example.EscapedChildAuth' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ # An escape this reader does not implement has to be refused, not missed.
+ printf '%s\n' \
+ '"authentic\q0061tion":' \
+ ' authenticator: com.example.UnresolvableAuth' \
+ > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ # A block scalar carries its content on the deeper lines. The indicator on
+ # its own is an empty string, which names no class, while the form with a
+ # class under it does name one.
+ printf '%s\n' 'authentication:' ' authenticator: |' > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticator: |' \
+ ' com.example.BlockAuth' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+
+ # The whole document as one flow mapping is a shape this reader does not
+ # walk. Answering none for it reported an authenticating Gremlin as
+ # unauthenticated, so it is refused until it is written the block way.
+ printf '%s\n' \
+ '{ host: 8182, authentication: { authenticator: org.example.Auth } }' \
+ > "${state_file}"
+ want_state nameless "$(yaml_auth_state)"
+
+ # YAML ends a line at a bare CR as much as at an LF. Gated on the host
+ # probe above because a reader that drops the CR byte is not observing the
+ # scanner here.
+ if [[ "${awk_sees_lone_cr}" == "1" ]]; then
+ printf 'host: 1\rauthentication:\r authenticator: com.example.CrAuth\r' \
+ > "${state_file}"
+ want_state named "$(yaml_auth_state)"
+ else
+ skip "the bare-CR yaml check -- this host eats the CR byte; it runs under CI"
+ fi
+
+ rm -f "${state_file}"
+ want_state none "$(yaml_auth_state)"
+)
+
+# check_auth_sides keeps the guarantee the class parsing used to serve: REST and
+# Gremlin never end up with authentication on one side only. Neither and both
+# pass; one side, or a mapping that names no authenticator, stops the boot.
+sides_dir="${test_dir}/sides"
+mkdir -p "${sides_dir}/conf"
+(
+ cd "${sides_dir}" || exit 1
+ REST_SERVER_CONF="./conf/rest-server.properties"
+
+ must_refuse() {
+ if check_auth_sides; then
+ echo "check_auth_sides must refuse: $1" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' 'host: 0.0.0.0' > conf/gremlin-server.yaml
+ : > "${REST_SERVER_CONF}"
+ check_auth_sides
+
+ # Both sides configured, different classes: untouched. enable-auth.sh's
+ # per-file guards then make its appends no-ops, so nothing here has to
+ # know which class either side names.
+ printf '%s\n' 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ > "${REST_SERVER_CONF}"
+ printf '%s\n' 'authentication:' ' authenticator: com.example.OtherAuth' \
+ > conf/gremlin-server.yaml
+ check_auth_sides
+ grep -Eq '^[[:blank:]]*auth[\\]?\.authenticator[[:blank:]]*([:=]|[[:blank:]])com\.example\.OtherAuth' \
+ "${REST_SERVER_CONF}" && {
+ echo "check_auth_sides must not copy a class into rest-server.properties" >&2
+ exit 1
+ }
+
+ # One side only.
+ printf '%s\n' 'auth.authenticator=com.example.MyAuth' > "${REST_SERVER_CONF}"
+ printf '%s\n' 'host: 0.0.0.0' > conf/gremlin-server.yaml
+ must_refuse "rest-server.properties names an authenticator and the yaml does not"
+
+ : > "${REST_SERVER_CONF}"
+ printf '%s\n' 'authentication:' ' authenticator: com.example.YamlAuth' \
+ > conf/gremlin-server.yaml
+ must_refuse "the yaml names an authenticator and rest-server.properties does not"
+
+ # A mapping that names no authenticator is refused even when REST is empty:
+ # enable-auth.sh guards on the presence of `authentication:`, so it would
+ # write the REST file alone and leave Gremlin unauthenticated.
+ printf '%s\n' 'authentication:' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ > conf/gremlin-server.yaml
+ : > "${REST_SERVER_CONF}"
+ must_refuse "the yaml mapping names no authenticator"
+ printf '%s\n' 'auth.authenticator=com.example.MyAuth' > "${REST_SERVER_CONF}"
+ must_refuse "the yaml mapping names no authenticator and REST does"
+)
+
+# The entrypoint refuses this tree before enable-auth.sh is ever reached, but
+# the script also runs on its own: the release tarball ships it with no
+# yamlscan.awk at all, so there is no check_auth_sides in front of it. Left to
+# itself it used to answer a mapping that names no authenticator by writing the
+# REST side alone -- StandardAuthenticator on REST beside TinkerPop's
+# AllowAllAuthenticator on Gremlin -- which is the one-sided boot this whole
+# guard exists to prevent. It has to refuse and change nothing, both with the
+# reader it shares with the entrypoint and on the grep fallback.
+nameless_tree() {
+ local dir="$1"
+ rm -rf "${dir}"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ if [[ "${2:-}" == "no-yamlscan" ]]; then
+ rm -f "${dir}/yamlscan.awk"
+ fi
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ : > "${dir}/conf/rest-server.properties"
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ > "${dir}/conf/gremlin-server.yaml"
+}
+
+# A mapping that names no class: refused by the entrypoint, so the script never
+# sees this tree through it.
+onesided_dir="${test_dir}/yaml-onesided"
+nameless_tree "${onesided_dir}"
+(
+ cd "${onesided_dir}" || exit 1
+ REST_SERVER_CONF="./conf/rest-server.properties"
+ if check_auth_sides; then
+ echo "check_auth_sides must refuse a yaml mapping without an authenticator" >&2
+ exit 1
+ fi
+)
+
+# refuse_nameless
: the script must stop before touching any config.
+refuse_nameless() {
+ local dir="$1" desc="$2"
+ (
+ cd "${dir}" || exit 1
+ if ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh must refuse a mapping that names no authenticator" >&2
+ exit 1
+ fi
+ if [[ -s conf/rest-server.properties ]]; then
+ echo "${desc}: a refused run still wrote rest-server.properties" >&2
+ exit 1
+ fi
+ if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then
+ echo "${desc}: a refused run still edited the yaml mapping" >&2
+ exit 1
+ fi
+ if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then
+ echo "${desc}: a refused run still wrapped the graph factory" >&2
+ exit 1
+ fi
+ )
+}
+
+refuse_nameless "${onesided_dir}" "image layout (yamlscan.awk present)"
+
+nameless_dir="${test_dir}/yaml-onesided-tarball"
+nameless_tree "${nameless_dir}" no-yamlscan
+refuse_nameless "${nameless_dir}" "release tarball (no yamlscan.awk)"
+
+# CRLF (Windows-saved) configs parse the way java.util.Properties reads
+# them: one trailing CR is a line terminator, not part of the value, and
+# a backslash before CRLF still continues the value onto the next line.
+# Untouched lines keep their CR bytes on rewrite.
+crlf_file="${test_dir}/config-crlf"
+printf 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator\r\n' > "${crlf_file}"
+# The backslash goes through %s on purpose: in one format string, `\\\r` is
+# reduced to a backslash followed by the letter r by some printf
+# implementations, which quietly turns this continuation case into a plain line
+# and makes the assertions below pass for the wrong reason.
+printf '%s\r\n' 'pd.peers=a,\' >> "${crlf_file}"
+printf ' b\r\n' >> "${crlf_file}"
+printf 'unrelated=true\r\n' >> "${crlf_file}"
+[[ "$(get_prop_encoded 'auth.authenticator' "${crlf_file}")" == \
+ "org.apache.hugegraph.auth.StandardAuthenticator" ]]
+[[ "$(get_prop_encoded 'pd.peers' "${crlf_file}")" == "a,b" ]]
+set_prop 'auth.authenticator' 'com.example.NewAuth' "${crlf_file}"
+grep -q '^auth\.authenticator=com\.example\.NewAuth$' "${crlf_file}"
+[[ "$(get_prop_encoded 'pd.peers' "${crlf_file}")" == "a,b" ]]
+if (( awk_sees_crlf_cr )); then
+ if ! grep -q $'^unrelated=true\r$' "${crlf_file}"; then
+ echo "CRLF bytes of untouched lines must be preserved" >&2
+ exit 1
+ fi
+else
+ skip "the CRLF byte check"
+fi
+
+# An escaped key is the same key: java.util.Properties unescapes the name, so
+# `auth\.authenticator` has to be found by a read or a write of
+# `auth.authenticator` instead of being treated as absent and appended beside.
+# (Comparing the class across the two files went away with the yaml scalar
+# parser, so only the key grammar is left to pin down here.)
+escaped_auth_dir="${test_dir}/escaped-auth-key"
+mkdir -p "${escaped_auth_dir}/conf"
+(
+ cd "${escaped_auth_dir}" || exit 1
+ REST_SERVER_CONF="./conf/rest-server.properties"
+ printf '%s\n' \
+ 'auth\.authenticator=com.example.OldAuth' \
+ 'unrelated=true' \
+ > "${REST_SERVER_CONF}"
+ [[ "$(get_prop_encoded 'auth.authenticator' "${REST_SERVER_CONF}")" == \
+ "com.example.OldAuth" ]]
+ set_prop 'auth.authenticator' 'com.example.NewAuth' "${REST_SERVER_CONF}"
+ assert_line_count 1 'auth[\\]?\.authenticator' "${REST_SERVER_CONF}"
+ grep -q '^auth\.authenticator=com\.example\.NewAuth$' "${REST_SERVER_CONF}"
+ assert_line_count 1 '^unrelated=true$' "${REST_SERVER_CONF}"
+)
+
+# A set must keep the config's inode: a copy-back preserves the file's
+# permissions (a 0600 config holding secrets must not come back
+# umask-readable) and leaves a symlinked config pointing at its target
+# instead of replacing it with a regular file.
+mode_file="${test_dir}/config-mode"
+printf '%s\n' 'unrelated=true' > "${mode_file}"
+chmod 600 "${mode_file}"
+set_prop "init_store.enabled" "true" "${mode_file}"
+grep -q '^init_store\.enabled=true$' "${mode_file}"
+grep -q '^unrelated=true$' "${mode_file}"
+[[ ! -e "${mode_file}.tmp" ]]
+[[ ! -e "${mode_file}.bak" ]]
+if (( host_keeps_chmod )); then
+ [[ "$(stat -c '%a' "${mode_file}")" == "600" ]]
+else
+ skip "the config-mode-preservation check"
+fi
+
+target_file="${test_dir}/config-target"
+link_file="${test_dir}/config-link"
+if (( host_keeps_symlink )); then
+ # The whole block has to be gated, not just the -L check: where ln -s
+ # produces a copy instead, writing the link updates a regular file and the
+ # target stays untouched, which would fail for the host's reason.
+ printf '%s\n' 'unrelated=true' > "${target_file}"
+ ln -s "${target_file}" "${link_file}"
+ set_prop "init_store.enabled" "true" "${link_file}"
+ [[ -L "${link_file}" ]] || {
+ echo "a set must not replace a symlinked config with a regular file" >&2
+ exit 1
+ }
+ grep -q '^init_store\.enabled=true$' "${target_file}"
+else
+ skip "the symlinked-config check"
+fi
+
+# Two yaml shapes the scoping has to keep getting right: a sibling mapping
+# that carries its own authenticator must not hide the block's, and a commented
+# authenticator must not count as one.
+scope_dir="${test_dir}/yaml-scope"
+mkdir -p "${scope_dir}/conf"
+(
+ cd "${scope_dir}" || exit 1
+ want_state() {
+ if [[ "$1" != "$2" ]]; then
+ echo "expected yaml state '$1', got '$2'" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticator: com.example.GremlinAuth' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ 'ssl:' \
+ ' authenticator: com.example.TlsOnly' \
+ > conf/gremlin-server.yaml
+ want_state named "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ '# authenticator: com.example.CommentedAuth' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ > conf/gremlin-server.yaml
+ want_state nameless "$(yaml_auth_state)"
+)
+
+# A child value can open a flow collection or a quoted scalar that its own line
+# does not close, and flow content ignores indentation, so the lines under it
+# belong to the nested node rather than to the authentication mapping. Reading
+# one of them as a direct child answered `named` for a file whose only
+# authenticator sits inside `authentication.config` -- the direction that leaves
+# REST enforcing beside a Gremlin on AllowAllAuthenticator. The two spellings
+# that open a flow mapping behind an anchor or on the first child line load fine
+# on the server, so answering `nameless` for them stops a boot that works.
+span_dir="${test_dir}/yaml-flow-span"
+mkdir -p "${span_dir}/conf"
+(
+ cd "${span_dir}" || exit 1
+ want_state() {
+ if [[ "$1" != "$2" ]]; then
+ echo "expected yaml state '$1', got '$2'" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' config: {tokens: conf/rest-server.properties,' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' \
+ > conf/gremlin-server.yaml
+ want_state nameless "$(yaml_auth_state)"
+
+ # A quoted scalar open past the end of the line has the same effect.
+ printf '%s\n' \
+ 'authentication:' \
+ ' config: {tokens: "conf/rest-server.properties,' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator"}' \
+ > conf/gremlin-server.yaml
+ want_state nameless "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication: &auth {authenticator: org.apache.hugegraph.auth.StandardAuthenticator, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler}' \
+ > conf/gremlin-server.yaml
+ want_state named "$(yaml_auth_state)"
+
+ printf '%s\n' \
+ 'authentication:' \
+ ' {authenticator: org.apache.hugegraph.auth.StandardAuthenticator, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler}' \
+ > conf/gremlin-server.yaml
+ want_state named "$(yaml_auth_state)"
+
+ # The span ends where the collection closes, so a direct child written after
+ # it still counts: the mapping is not simply swallowed to the end of file.
+ printf '%s\n' \
+ 'authentication:' \
+ ' config: {tokens: conf/rest-server.properties,' \
+ ' handler: org.apache.hugegraph.auth.StandardAuthenticator}' \
+ ' authenticator: com.example.GremlinAuth' \
+ > conf/gremlin-server.yaml
+ want_state named "$(yaml_auth_state)"
+
+ # A collection that never closes is a file the server rejects, so it is
+ # refused through the nameless state rather than settled from a guess.
+ printf '%s\n' \
+ 'authentication:' \
+ ' config: {tokens: conf/rest-server.properties' \
+ ' authenticator: com.example.GremlinAuth' \
+ > conf/gremlin-server.yaml
+ want_state nameless "$(yaml_auth_state)"
+)
+
+# The consequence rather than the answer: this is the tree that used to be armed
+# one-sided, because a reader that said `named` told enable-auth.sh the yaml was
+# already configured, so it wrote the REST side alone and exited 0. It has to
+# refuse and change nothing. Note the guard on the yaml is on the properties
+# and the factory only: the file itself carries an authenticator line nested in
+# config, which is the shape under test.
+span_tree_dir="${test_dir}/yaml-flow-span-tree"
+mkdir -p "${span_tree_dir}/conf/graphs"
+install_enable_auth "${span_tree_dir}"
+printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${span_tree_dir}/conf/graphs/hugegraph.properties"
+: > "${span_tree_dir}/conf/rest-server.properties"
+printf '%s\n' \
+ 'authentication:' \
+ ' config: {tokens: conf/rest-server.properties,' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator}' \
+ > "${span_tree_dir}/conf/gremlin-server.yaml"
+(
+ cd "${span_tree_dir}" || exit 1
+ if ./bin/enable-auth.sh; then
+ echo "enable-auth.sh must refuse a mapping whose authenticator is nested in config" >&2
+ exit 1
+ fi
+ if [[ -s conf/rest-server.properties ]]; then
+ echo "a refused run still wrote rest-server.properties" >&2
+ exit 1
+ fi
+ if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then
+ echo "a refused run still wrapped the graph factory" >&2
+ exit 1
+ fi
+)
+
+# Both sides silent means "bootstrap authentication", and the class then comes
+# from enable-auth.sh: an operator who passed AUTHENTICATOR_CLASS gets the class
+# they asked for, and only an unset one falls back to StandardAuthenticator.
+# With the entrypoint no longer exporting a class of its own, this is the whole
+# of the guarantee, so it is asserted where the default now lives.
+class_dir="${test_dir}/authenticator-class"
+(
+ # A fresh tree per run: enable-auth.sh keeps its own backup of the configs
+ # it writes, so re-running it over one directory is not a clean case.
+ run_enable_auth() {
+ local dir="$1" want="$2"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ : > "${dir}/conf/rest-server.properties"
+ : > "${dir}/conf/gremlin-server.yaml"
+ (
+ cd "${dir}" || exit 1
+ if [[ -n "${want}" ]]; then
+ AUTHENTICATOR_CLASS="${want}"
+ export AUTHENTICATOR_CLASS
+ else
+ unset AUTHENTICATOR_CLASS
+ fi
+ ./bin/enable-auth.sh
+ )
+ }
+
+ run_enable_auth "${class_dir}/operator" "com.example.OperatorAuth"
+ grep -q '^auth\.authenticator=com\.example\.OperatorAuth$' \
+ "${class_dir}/operator/conf/rest-server.properties"
+ grep -q '^ authenticator: com\.example\.OperatorAuth,$' \
+ "${class_dir}/operator/conf/gremlin-server.yaml"
+
+ run_enable_auth "${class_dir}/default" ""
+ grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \
+ "${class_dir}/default/conf/rest-server.properties"
+ grep -q '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \
+ "${class_dir}/default/conf/gremlin-server.yaml"
+
+ # refused_class : a class the writer would reject has to be
+ # rejected before any config is touched, and the tree has to still be
+ # workable afterwards. The character check used to sit only in props_set,
+ # which runs at the rest-server.properties write one statement after the
+ # yaml block was appended with that same class, so a refused value left
+ # gremlin-server.yaml naming a class no server can load beside an empty
+ # REST config -- the one-sided tree check_auth_sides stops the next boot on.
+ # It did not self-repair: the yaml then reads as `named`, so the append was
+ # skipped and a rerun with the variable fixed wrote the default to REST only
+ # and exited 0, leaving the two servers on different authenticators.
+ refused_class() {
+ local dir="$1" desc="$2"
+ (
+ cd "${dir}" || exit 1
+ if AUTHENTICATOR_CLASS='com.example.My Auth' ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh must refuse a class its writer rejects" >&2
+ exit 1
+ fi
+ if [[ -s conf/rest-server.properties ]]; then
+ echo "${desc}: a refused class still wrote rest-server.properties" >&2
+ exit 1
+ fi
+ if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then
+ echo "${desc}: a refused class still edited gremlin-server.yaml" >&2
+ exit 1
+ fi
+ if grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties; then
+ echo "${desc}: a refused class still wrapped the graph factory" >&2
+ exit 1
+ fi
+ # Nothing was left behind, so the corrected run arms both sides with
+ # one class rather than adopting the half-written tree.
+ unset AUTHENTICATOR_CLASS
+ if ! ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh failed on the tree a refused run left" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \
+ conf/rest-server.properties; then
+ echo "${desc}: the corrected run wrote no class to REST" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \
+ conf/gremlin-server.yaml; then
+ echo "${desc}: the corrected run named no class in the yaml" >&2
+ exit 1
+ fi
+ )
+ }
+
+ refused_tree() {
+ local dir="$1" with_scan="$2"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ if [[ "${with_scan}" == "no-yamlscan" ]]; then
+ rm -f "${dir}/yamlscan.awk"
+ fi
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ : > "${dir}/conf/rest-server.properties"
+ : > "${dir}/conf/gremlin-server.yaml"
+ }
+
+ # Both layouts, because the append happens in both: the image has the real
+ # reader, the plain tarball answers `none` from grep on an empty file.
+ refused_tree "${class_dir}/refused-image" yes
+ refused_class "${class_dir}/refused-image" "image layout (yamlscan.awk present)"
+ refused_tree "${class_dir}/refused-tarball" no-yamlscan
+ refused_class "${class_dir}/refused-tarball" "release tarball (no yamlscan.awk)"
+)
+
+# An empty mounted config still gets its definitions. GNU sed's `$`
+# address never matches when the file has no lines, so enable-auth.sh's
+# `sed -i '$a\...'` appends were silent no-ops on an empty
+# rest-server.properties and an empty gremlin-server.yaml: the
+# entrypoint had already written auth.admin_pa and init-store had run in
+# auth mode, yet neither server was told to authenticate at all.
+empty_dir="${test_dir}/empty-config"
+mkdir -p "${empty_dir}/bin" "${empty_dir}/conf/graphs"
+install_enable_auth "${empty_dir}"
+(
+ cd "${empty_dir}" || exit 1
+ : > conf/rest-server.properties
+ : > conf/gremlin-server.yaml
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > conf/graphs/hugegraph.properties
+ unset AUTHENTICATOR_CLASS
+ ./bin/enable-auth.sh
+ grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \
+ conf/rest-server.properties
+ grep -q '^auth\.graph_store=hugegraph$' conf/rest-server.properties
+ grep -q '^authentication: {$' conf/gremlin-server.yaml
+ grep -q '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \
+ conf/gremlin-server.yaml
+ grep -q '^ config: {tokens: conf/rest-server\.properties}$' \
+ conf/gremlin-server.yaml
+ grep -q '^}' conf/gremlin-server.yaml
+ grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties
+ # Idempotent: a second run adds nothing to what the first one wrote.
+ wc -l < conf/gremlin-server.yaml > "${test_dir}/empty-yaml-count"
+ ./bin/enable-auth.sh
+ [[ "$(wc -l < conf/gremlin-server.yaml)" == \
+ "$(cat "${test_dir}/empty-yaml-count")" ]]
+
+ # A config whose last line has no terminator still gets a line of its
+ # own; `sed -i '$a'` closed that terminator for us.
+ printf 'restserver.url=http://127.0.0.1:8080' > conf/rest-server.properties
+ ./bin/enable-auth.sh
+ grep -q '^auth\.authenticator=' conf/rest-server.properties
+ grep -q '^restserver\.url=http://127\.0\.0\.1:8080$' conf/rest-server.properties
+)
+
+# A copy-back that fails part way must not leave a truncated config. The
+# shell's `>` truncates the destination before cat writes a byte, so
+# props.awk snapshots the original first and puts it back. The snapshot
+# `cat` is replaced through PATH to fail the copy the way ENOSPC would:
+# stdout here *is* the already-truncated destination, so a few bytes and a
+# non-zero exit is exactly a half-written config.
+#
+# The staging names are unpredictable now, so this group follows the paths
+# props.awk left behind instead of naming `.tmp` and `.bak`: a
+# fixed name is the thing that had to go, and an assertion that has to guess
+# the name would have to be rewritten every time the generator changes.
+failbin="${test_dir}/fakebin"
+mkdir -p "${failbin}"
+real_cat="$(command -v cat)"
+printf '%s\n' \
+ '#!/bin/sh' \
+ 'case "$2" in' \
+ ' *.tmp*) printf "auth.authenticator=par"; exit 1 ;;' \
+ ' *.bak*) [ -n "${FAKE_BAK_FAIL:-}" ] && exit 1 ;;' \
+ 'esac' \
+ 'exec "${FAKE_CAT_REAL}" "$@"' \
+ > "${failbin}/cat"
+chmod +x "${failbin}/cat"
+rb_file="${test_dir}/config-rollback"
+rb_expect="${test_dir}/config-rollback.expected"
+printf '%s\n' \
+ 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ 'auth.token_secret=s3cr3t' \
+ 'unrelated=true' > "${rb_file}"
+cp -p "${rb_file}" "${rb_expect}"
+staged() {
+ find "${test_dir}" -maxdepth 1 -name "config-rollback.$1.*" | sort
+}
+(
+ PATH="${failbin}:${PATH}"
+ FAKE_CAT_REAL="${real_cat}"
+ export PATH FAKE_CAT_REAL
+ if set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}"; then
+ echo "set_prop must fail when the copy-back fails" >&2
+ exit 1
+ fi
+) 2>/dev/null
+cmp -s "${rb_file}" "${rb_expect}" || {
+ echo "a failed copy-back must leave the previous content in place" >&2
+ exit 1
+}
+# Both staging files survive on purpose: the temp file is what was being
+# written, and the snapshot is the operator's way back.
+rb_left_tmp=$(staged tmp | head -1)
+rb_left_bak=$(staged bak | head -1)
+[[ -n "${rb_left_tmp}" && -n "${rb_left_bak}" ]] || {
+ echo "a failed copy-back must leave both staging files for the operator" >&2
+ exit 1
+}
+# Once the condition clears the same set goes through, and leaves nothing
+# behind. The operator's leftovers go first, so a leftover from this run cannot
+# be mistaken for one from that run.
+rm -f "${rb_left_tmp}" "${rb_left_bak}"
+set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}"
+grep -q '^auth\.authenticator=com\.example\.HalfWritten$' "${rb_file}"
+grep -q '^auth\.token_secret=s3cr3t$' "${rb_file}"
+grep -q '^unrelated=true$' "${rb_file}"
+[[ -z "$(staged tmp)$(staged bak)" ]] || {
+ echo "a successful set left staging files behind: $(staged tmp) $(staged bak)" >&2
+ exit 1
+}
+# When the restore fails too there is nothing left to do but say so and
+# point at the snapshot, because that snapshot is the only copy of a
+# working config the operator has.
+printf '%s\n' \
+ 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ 'auth.token_secret=s3cr3t' \
+ 'unrelated=true' > "${rb_file}"
+rb_out=$(
+ PATH="${failbin}:${PATH}"
+ FAKE_CAT_REAL="${real_cat}"
+ FAKE_BAK_FAIL=1
+ export PATH FAKE_CAT_REAL FAKE_BAK_FAIL
+ set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}" 2>&1
+) || true
+rb_bak=$(staged bak)
+[[ "${rb_out}" == *"${rb_file}.bak."* ]] || {
+ echo "props.awk must name the snapshot when the restore also fails, got [${rb_out}]" >&2
+ exit 1
+}
+# The damaged config keeps whatever the aborted copy left, and the
+# snapshot still holds the last known good content.
+[[ -n "${rb_bak}" ]] || { echo "the snapshot is gone" >&2; exit 1; }
+cmp -s "${rb_bak}" "${rb_expect}" || {
+ echo "the snapshot must be a byte-for-byte copy of the original" >&2
+ exit 1
+}
+
+# ── A config that cannot be written is refused before anything is staged ──
+# The copy-back was the first write props.awk attempted, so a read-only config
+# failed there and got the message written for a copy that reached the
+# destination and then broke. The shell opens the destination for writing before
+# cat has written a byte, so on EACCES nothing was truncated and the config is
+# intact, yet the run called it damaged and sent the operator to restore a file
+# that needed nothing -- while leaving the staged temp file, which holds the value
+# being written, and the snapshot beside it. A container on a restart policy
+# therefore gained one pair per restart, each temp file carrying the secret.
+# Repeating the attempt three times is the point: the leak is per attempt.
+# The group only means something on a host that stops a write to 0444, so it is
+# gated on the probe that tries it rather than weakened into a pass anywhere.
+ro_staged() {
+ find "${test_dir}" -maxdepth 1 -name "config-readonly.$1.*" | sort
+}
+if (( host_denies_write )); then
+ ro_file="${test_dir}/config-readonly"
+ ro_pristine="${test_dir}/config-readonly.pristine"
+ printf '%s\n' 'server.name=hugegraph' > "${ro_file}"
+ cp "${ro_file}" "${ro_pristine}"
+ chmod 444 "${ro_file}"
+ for ro_attempt in 1 2 3; do
+ if ro_out=$(set_prop 'auth.admin_pa' 's3cretVALUE' "${ro_file}" 2>&1); then
+ chmod 644 "${ro_file}"
+ echo "set_prop must refuse a config it cannot write" >&2
+ exit 1
+ fi
+ if [[ "${ro_out}" != *"not writable"* ]]; then
+ chmod 644 "${ro_file}"
+ echo "a refused set must say the config is not writable, got [${ro_out}]" >&2
+ exit 1
+ fi
+ if [[ "${ro_out}" == *"damaged"* ]]; then
+ chmod 644 "${ro_file}"
+ echo "a config nothing wrote to must not be called damaged, got [${ro_out}]" >&2
+ exit 1
+ fi
+ done
+ chmod 644 "${ro_file}"
+ cmp -s "${ro_file}" "${ro_pristine}" || {
+ echo "a refused set must leave the config byte-for-byte untouched" >&2
+ exit 1
+ }
+ [[ -z "$(ro_staged tmp)$(ro_staged bak)" ]] || {
+ echo "a refused set left staging files behind: $(ro_staged tmp) $(ro_staged bak)" >&2
+ exit 1
+ }
+ # Refusing to write a read-only config must not turn into refusing to read
+ # one, which is what the entrypoint does first on every boot.
+ [[ "$(get_prop_decoded 'server.name' "${ro_file}")" == "hugegraph" ]] || {
+ echo "get_prop must still answer from a read-only config" >&2
+ exit 1
+ }
+ # A write that cannot even begin is the same case arriving a different way:
+ # `test -w` can report a bind-mounted read-only file as writable, so the
+ # compare against the snapshot has to hold on its own. A `cat` that fails
+ # without writing simulates it, and an empty config is the shape where that
+ # really does leave the original intact, so this exercises the compare rather
+ # than re-testing the redirect above.
+ nowrite_bin="${test_dir}/fakebin-nowrite"
+ mkdir -p "${nowrite_bin}"
+ printf '%s\n' '#!/bin/sh' 'exit 1' > "${nowrite_bin}/cat"
+ chmod +x "${nowrite_bin}/cat"
+ nm_file="${test_dir}/config-notmodified"
+ : > "${nm_file}"
+ if nm_out=$(
+ PATH="${nowrite_bin}:${PATH}"
+ FAKE_CAT_REAL="${real_cat}"
+ export PATH FAKE_CAT_REAL
+ set_prop 'auth.token_secret' 's3cretVALUE' "${nm_file}" 2>&1
+ ); then
+ echo "set_prop must fail when the copy-back cannot run" >&2
+ exit 1
+ fi
+ [[ "${nm_out}" == *"unchanged"* ]] || {
+ echo "a copy-back that never started must say the config is unchanged, got [${nm_out}]" >&2
+ exit 1
+ }
+ [[ -s "${nm_file}" ]] && { echo "the config gained content from a refused write" >&2; exit 1; }
+ [[ -z "$(find "${test_dir}" -maxdepth 1 -name "config-notmodified.*" | grep -v pristine)" ]] || {
+ echo "a refused copy-back that left the config intact must remove both staging files" >&2
+ exit 1
+ }
+else
+ skip "the read-only-config group -- this host lets a 0444 file be written"
+fi
+
+# A value whose encoded form ends in an odd number of backslashes must not be
+# written at all. The entrypoint copies an existing secret between files with
+# set_prop_encoded, replaying the raw bytes, and on disk `key=abc\` as the last
+# line of a mounted config reads back as no property at all under
+# commons-configuration2 (what HugeConfig extends). Written into a file where
+# it is no longer last, it turns the following line into a continuation of the
+# secret: the server then sees neither the secret nor that property, and the
+# entrypoint has published a credential nothing will read.
+bs_file="${test_dir}/config-trailing-backslash"
+bs_pristine="${test_dir}/config-trailing-backslash.pristine"
+printf '%s\n' 'unrelated=true' > "${bs_file}"
+cp "${bs_file}" "${bs_pristine}"
+if set_prop_encoded 'auth.token_secret' 'abc\' "${bs_file}" 2>/dev/null; then
+ echo "props.awk must refuse a value ending in an odd number of backslashes" >&2
+ exit 1
+fi
+cmp -s "${bs_file}" "${bs_pristine}" || {
+ echo "a refused set must leave the config byte-for-byte untouched" >&2
+ exit 1
+}
+
+# An escaped backslash — two of them — is not a continuation, so it stays
+# writable and replays byte for byte. Built from parts because a doubled
+# backslash inside one literal is easy to write and hard to read back.
+bs='\'
+two_bs="abc${bs}${bs}"
+set_prop_encoded 'auth.token_secret' "${two_bs}" "${bs_file}"
+[[ "$(get_prop_encoded 'auth.token_secret' "${bs_file}")" == "${two_bs}" ]]
+assert_line_count 1 '^unrelated=true$' "${bs_file}"
+
+# ── CR-only line terminators ──────────────────────────────────────────
+# java.util.Properties ends a line at a bare CR as well, so a config written
+# that way holds one property per CR-separated chunk. Reading it with a
+# \n-only split made the entire file one record: only the first key was ever
+# seen, and rewriting that key replaced the record with a single line, which
+# silently deleted every property after it -- including auth.authenticator, so
+# the file the server then read had no authentication configured at all.
+if (( awk_sees_lone_cr )); then
+ cr_file="${test_dir}/config-cr"
+ printf 'graph=a\rpd.peers=b\rauth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator\r' \
+ > "${cr_file}"
+
+ [[ "$(get_prop_encoded 'graph' "${cr_file}")" == "a" ]]
+ [[ "$(get_prop_encoded 'pd.peers' "${cr_file}")" == "b" ]]
+ [[ "$(get_prop_encoded 'auth.authenticator' "${cr_file}")" == \
+ "org.apache.hugegraph.auth.StandardAuthenticator" ]]
+
+ cp "${cr_file}" "${cr_file}.before"
+ set_prop 'graph' 'org.apache.hugegraph.auth.HugeFactoryAuthProxy' "${cr_file}"
+
+ # Every key that was there before is still there afterwards, with the
+ # values the rewrite was not about.
+ [[ "$(get_prop_encoded 'pd.peers' "${cr_file}")" == "b" ]] || {
+ echo "a CR-only config lost pd.peers when an unrelated key was rewritten" >&2
+ exit 1
+ }
+ [[ "$(get_prop_encoded 'auth.authenticator' "${cr_file}")" == \
+ "org.apache.hugegraph.auth.StandardAuthenticator" ]] || {
+ echo "a CR-only config lost auth.authenticator when an unrelated key was rewritten" >&2
+ exit 1
+ }
+ [[ "$(get_prop_encoded 'graph' "${cr_file}")" == \
+ "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]]
+ # One definition per key, so the rewrite replaced rather than appended.
+ # Counted on CR folded to LF because grep only ever starts a new line at
+ # LF, and a CR-only file is a single line to it.
+ count_records() {
+ local pattern="$1" file="$2"
+ # grep exits 1 on a zero count, which errexit would take as the
+ # interesting failure; the printed number is the answer here.
+ tr '\r' '\n' < "${file}" | grep -Ec "${pattern}" || true
+ }
+ [[ "$(count_records '^graph=' "${cr_file}")" == "1" ]] || {
+ echo "a CR-only rewrite must leave exactly one graph definition" >&2
+ exit 1
+ }
+ [[ "$(count_records '^auth\.authenticator=' "${cr_file}")" == "1" ]] || {
+ echo "a CR-only rewrite must leave exactly one auth.authenticator definition" >&2
+ exit 1
+ }
+
+ # Mixed terminators in one file, the state an upgraded mounted volume
+ # actually reaches: CRLF from a Windows edit, CR from an old store(), LF
+ # from the image.
+ mix_file="${test_dir}/config-mixed-eol"
+ printf 'graph=a\rpd.peers=b\nauth.authenticator=c\r\nunrelated=d\n' > "${mix_file}"
+ [[ "$(get_prop_encoded 'graph' "${mix_file}")" == "a" ]]
+ [[ "$(get_prop_encoded 'pd.peers' "${mix_file}")" == "b" ]]
+ [[ "$(get_prop_encoded 'auth.authenticator' "${mix_file}")" == "c" ]]
+ [[ "$(get_prop_encoded 'unrelated' "${mix_file}")" == "d" ]]
+fi
+
+# ── Form feed is separator whitespace to Java ─────────────────────────
+# java.util.Properties counts \f as whitespace on both sides of the key/value
+# boundary, so `auth.authenticator=...` is that property. Recognising only
+# space and tab parsed the form feed into the key name instead, and a mounted
+# config written that way read as unconfigured -- which the guards then answered
+# by appending a second, competing definition.
+ff_file="${test_dir}/config-formfeed"
+printf 'auth.authenticator\fs=org.apache.hugegraph.auth.StandardAuthenticator\n' > "${ff_file}"
+[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == \
+ "s=org.apache.hugegraph.auth.StandardAuthenticator" ]] || {
+ echo "a form feed before the separator must end the key, as it does in Java" >&2
+ exit 1
+}
+printf 'auth.authenticator\forg.apache.hugegraph.auth.X\n' > "${ff_file}"
+[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == \
+ "org.apache.hugegraph.auth.X" ]]
+printf 'auth.authenticator=\f1\n' > "${ff_file}"
+[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == "1" ]]
+printf '\fauth.authenticator=1\n' > "${ff_file}"
+[[ "$(get_prop_encoded 'auth.authenticator' "${ff_file}")" == "1" ]]
+# A line that is only form feed whitespace is blank to Java, not a property.
+printf '\f\f\ngraph=a\n' > "${ff_file}"
+[[ "$(get_prop_encoded 'graph' "${ff_file}")" == "a" ]]
+assert_line_count 1 '^graph=a$' "${ff_file}"
+
+# get answers absence with empty output at exit 0, and an empty definition
+# reads back the same way -- which is why ensure_rest_prop tests the value
+# rather than a presence status. The one nonzero case is a file the reader
+# cannot answer a question about, and it must not be read as "absent": a guard
+# wearing errexit has to stop rather than append a default over a file it could
+# not read.
+get_status_file="${test_dir}/config-get-status"
+printf 'auth.authenticator=\n' > "${get_status_file}"
+[[ -z "$(get_prop_encoded 'auth.authenticator' "${get_status_file}")" ]] || {
+ echo "an empty definition must read back empty" >&2
+ exit 1
+}
+if ! get_prop_encoded 'auth.graph_store' "${get_status_file}" >/dev/null; then
+ echo "an absent key in a readable file must exit 0" >&2
+ exit 1
+fi
+status=0
+get_prop_encoded 'k' "${test_dir}/no-such-file" >/dev/null 2>&1 || status=$?
+if (( status != 2 )); then
+ echo "get must exit 2 for an unreadable file, got ${status}" >&2
+ exit 1
+fi
+
+# get with PROPS_DECODED=1 hands back the value as the server would see it,
+# which is what a guard that compares a class name needs.
+dec_file="${test_dir}/config-decoded"
+printf 'gremlin\\u002egraph=org.apache.hugegraph.HugeFactory\n' > "${dec_file}"
+[[ "$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY='gremlin.graph' \
+ PROPS_FILE="${dec_file}" awk -f "${PROPS_AWK}" /dev/null)" == \
+ "org.apache.hugegraph.HugeFactory" ]]
+[[ "$(PROPS_MODE=get PROPS_KEY='gremlin\u002egraph' PROPS_FILE="${dec_file}" \
+ awk -f "${PROPS_AWK}" /dev/null)" == "" ]]
+
+# ── gremlin.graph spelled with a Unicode escape still gets wrapped ──────
+# \u002e is a dot to java.util.Properties, so this is the plain HugeFactory and
+# enable-auth.sh has to route authentication through it. The grep/sed pair
+# matched only a literal or backslash-escaped dot, missed this one, and left the
+# graph factory unwrapped while both servers had been told authentication was
+# on -- the one remaining path where the REST side was configured and the graph
+# behind it was not.
+u2e_dir="${test_dir}/u2e-wrap"
+mkdir -p "${u2e_dir}/conf/graphs"
+install_enable_auth "${u2e_dir}"
+: > "${u2e_dir}/conf/rest-server.properties"
+: > "${u2e_dir}/conf/gremlin-server.yaml"
+printf '%s\n' 'gremlin\u002egraph=org.apache.hugegraph.HugeFactory' \
+ > "${u2e_dir}/conf/graphs/hugegraph.properties"
+(
+ cd "${u2e_dir}" || exit 1
+ unset AUTHENTICATOR_CLASS
+ ./bin/enable-auth.sh
+ if [[ "$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY='gremlin.graph' \
+ PROPS_FILE=./conf/graphs/hugegraph.properties \
+ awk -f "${PROPS_AWK}" /dev/null)" != \
+ "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]]; then
+ echo "a gremlin.graph key written as \\u002e must still be wrapped" >&2
+ exit 1
+ fi
+ # One definition, not the original left behind plus a new one.
+ if [[ "$(grep -c 'HugeFactory' ./conf/graphs/hugegraph.properties)" != "1" ]]; then
+ echo "wrapping a \\u002e-escaped key must not leave the old definition" >&2
+ exit 1
+ fi
+)
+
+# A CR-only graph config wraps too, and keeps the keys around it.
+if (( awk_sees_lone_cr )); then
+ crwrap_dir="${test_dir}/cr-wrap"
+ mkdir -p "${crwrap_dir}/conf/graphs"
+ install_enable_auth "${crwrap_dir}"
+ : > "${crwrap_dir}/conf/rest-server.properties"
+ : > "${crwrap_dir}/conf/gremlin-server.yaml"
+ printf 'gremlin.graph=org.apache.hugegraph.HugeFactory\rbackend=rocksdb\r' \
+ > "${crwrap_dir}/conf/graphs/hugegraph.properties"
+ (
+ cd "${crwrap_dir}" || exit 1
+ unset AUTHENTICATOR_CLASS
+ ./bin/enable-auth.sh
+ [[ "$(get_prop_encoded 'backend' ./conf/graphs/hugegraph.properties)" == \
+ "rocksdb" ]] || {
+ echo "wrapping a CR-only graph config dropped a later key" >&2
+ exit 1
+ }
+ [[ "$(get_prop_encoded 'gremlin.graph' ./conf/graphs/hugegraph.properties)" == \
+ "org.apache.hugegraph.auth.HugeFactoryAuthProxy" ]]
+ )
+fi
+
+# ── A failed append must fail the script ──────────────────────────────
+# The entrypoint runs enable-auth.sh and trusts its exit status, so a run that
+# configures REST and then cannot write the yaml has to say so. Without
+# errexit and per-write checks it exited 0 on exactly that half-done tree: the
+# mounted read-only gremlin-server.yaml made the yaml append fail while both
+# rest-server.properties appends succeeded.
+ro_dir="${test_dir}/read-only-yaml"
+mkdir -p "${ro_dir}/conf/graphs"
+install_enable_auth "${ro_dir}"
+: > "${ro_dir}/conf/rest-server.properties"
+printf 'host: 8182\n' > "${ro_dir}/conf/gremlin-server.yaml"
+printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${ro_dir}/conf/graphs/hugegraph.properties"
+(
+ cd "${ro_dir}" || exit 1
+ unset AUTHENTICATOR_CLASS
+ chmod 444 conf/gremlin-server.yaml
+ status=0
+ ./bin/enable-auth.sh 2>/dev/null || status=$?
+ chmod 644 conf/gremlin-server.yaml
+ if (( status == 0 )); then
+ echo "enable-auth.sh must exit nonzero when a config append fails" >&2
+ exit 1
+ fi
+ if grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' conf/gremlin-server.yaml; then
+ echo "the unwritable yaml file must not have been changed" >&2
+ exit 1
+ fi
+)
+
+# ── A refused read has to precede every write ──────────────────────────
+# The block above covers a write that fails; this covers a read that fails.
+# A rest-server.properties carrying commons-configuration's `include` directive
+# is a file props.awk refuses, and the script used to reach it only at
+# ensure_rest_prop -- one statement after the yaml block had been appended. Its
+# guard also tested `[[ -n "$(props_get ...)" ]]`, and `[[` keeps the text of a
+# command substitution while discarding its status, so props_get's refusal only
+# exited the subshell and the REST write went ahead too. The run left
+# gremlin-server.yaml naming StandardAuthenticator beside a rest-server.properties
+# with no authenticator at all, and in the release tarball that does not repair
+# itself: the appended block reads as a mapping, so the rerun after the operator
+# removes the include line stops on the unverifiable branch and the yaml has to
+# be edited by hand. graphs/hugegraph.properties was read last of all, so an
+# include directive there let the run write both auth sides and leave the factory
+# unwrapped. Every value the script decides on is now read before the first
+# write, so a refusal leaves the tree exactly as the operator left it, and one
+# run after the repair arms both sides.
+refused_read_tree() {
+ local dir="$1" with_scan="$2" unreadable="$3"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ if [[ "${with_scan}" == "no-yamlscan" ]]; then
+ rm -f "${dir}/yamlscan.awk"
+ fi
+ printf '%s\n' 'server.name=hugegraph' > "${dir}/conf/rest-server.properties"
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ printf 'host: 8182\n' > "${dir}/conf/gremlin-server.yaml"
+ printf '%s\n' 'include=other.properties' >> "${dir}/conf/${unreadable}"
+}
+
+refused_read() {
+ local dir="$1" desc="$2" unreadable="$3"
+ (
+ cd "${dir}" || exit 1
+ keep="${dir}.found"
+ mkdir -p "${keep}/graphs"
+ cp conf/gremlin-server.yaml conf/rest-server.properties "${keep}/"
+ cp conf/graphs/hugegraph.properties "${keep}/graphs/"
+ if ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh must refuse a config props.awk cannot read" >&2
+ exit 1
+ fi
+ for kept in gremlin-server.yaml rest-server.properties; do
+ if ! cmp -s "conf/${kept}" "${keep}/${kept}"; then
+ echo "${desc}: a refused read still edited ${kept}" >&2
+ exit 1
+ fi
+ done
+ if ! cmp -s conf/graphs/hugegraph.properties \
+ "${keep}/graphs/hugegraph.properties"; then
+ echo "${desc}: a refused read still edited graphs/hugegraph.properties" >&2
+ exit 1
+ fi
+ # The operator's repair, then one run over a tree that is back to the
+ # shipped shape. Rewritten rather than filtered so no CR can enter the
+ # fixture on this host.
+ if [[ "${unreadable}" == "rest-server.properties" ]]; then
+ printf '%s\n' 'server.name=hugegraph' > conf/rest-server.properties
+ else
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > conf/graphs/hugegraph.properties
+ fi
+ if ! ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh failed on the repaired tree" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \
+ conf/rest-server.properties; then
+ echo "${desc}: the repaired run wrote no class to REST" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \
+ conf/gremlin-server.yaml; then
+ echo "${desc}: the repaired run named no class in the yaml" >&2
+ exit 1
+ fi
+ if ! grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \
+ conf/graphs/hugegraph.properties; then
+ echo "${desc}: the repaired run left the graph factory unwrapped" >&2
+ exit 1
+ fi
+ )
+}
+
+for scan in yes no-yamlscan; do
+ case "${scan}" in
+ yes) scan_desc="image layout (yamlscan.awk present)" ;;
+ *) scan_desc="release tarball (no yamlscan.awk)" ;;
+ esac
+ for unreadable in rest-server.properties graphs/hugegraph.properties; do
+ dir="${test_dir}/refused-read-${scan}-${unreadable##*/}"
+ refused_read_tree "${dir}" "${scan}" "${unreadable}"
+ refused_read "${dir}" "${scan_desc}, ${unreadable} unreadable" "${unreadable}"
+ done
+done
+
+# ── A config that cannot be written has to precede every write too ──────
+# The group above covers a read that fails; this covers a write that cannot
+# start. The read-only gremlin-server.yaml already had a case, but the mirror
+# image did not: append_lines guards only the file it appends to, so a
+# rest-server.properties that can be read and not written let the yaml gain the
+# authentication block, props_set then failed on the REST side, and the run
+# exited 1 on the one-sided tree the comment above append_lines says must not
+# happen -- which is precisely what gremlin-server.yaml naming
+# StandardAuthenticator beside a REST config with no auth.authenticator is.
+# graphs/hugegraph.properties had the same hole one side further along: both
+# auth sides got written and the factory stayed unwrapped. In the release
+# tarball the tree did not repair itself either, because the appended block reads
+# as a mapping, so the rerun after the operator restored write access stopped on
+# the unverifiable branch and the yaml had to be edited by hand. So each
+# permutation must refuse with every byte of every config where it was, leave no
+# staging file behind, and arm both sides and wrap the factory once write access
+# returns.
+refused_write_tree() {
+ local dir="$1" with_scan="$2" unwritable="$3"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ if [[ "${with_scan}" == "no-yamlscan" ]]; then
+ rm -f "${dir}/yamlscan.awk"
+ fi
+ printf '%s\n' 'server.name=hugegraph' > "${dir}/conf/rest-server.properties"
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ printf 'host: 8182\n' > "${dir}/conf/gremlin-server.yaml"
+ chmod 444 "${dir}/conf/${unwritable}"
+}
+
+refused_write() {
+ local dir="$1" desc="$2" unwritable="$3"
+ (
+ cd "${dir}" || exit 1
+ keep="${dir}.kept"
+ mkdir -p "${keep}/graphs"
+ cp conf/gremlin-server.yaml conf/rest-server.properties "${keep}/"
+ cp conf/graphs/hugegraph.properties "${keep}/graphs/"
+ if ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh must refuse a config it cannot write" >&2
+ exit 1
+ fi
+ for kept in gremlin-server.yaml rest-server.properties; do
+ if ! cmp -s "conf/${kept}" "${keep}/${kept}"; then
+ echo "${desc}: a refused write still edited ${kept}" >&2
+ exit 1
+ fi
+ done
+ if ! cmp -s conf/graphs/hugegraph.properties \
+ "${keep}/graphs/hugegraph.properties"; then
+ echo "${desc}: a refused write still edited graphs/hugegraph.properties" >&2
+ exit 1
+ fi
+ # The staged temp file holds the value being written, so a refusal that
+ # leaves it in a mounted conf directory is a leak, not a diagnostic.
+ if find conf -name '*.tmp.*' -o -name '*.bak.*' | grep -q .; then
+ echo "${desc}: a refused write left staging files behind" >&2
+ find conf -name '*.tmp.*' -o -name '*.bak.*' >&2
+ exit 1
+ fi
+ # The operator's repair, then one run over the tree that was never
+ # touched. This is what the refused case buys: the yaml cannot have a
+ # half-written mapping for the rerun to misread.
+ chmod 644 "conf/${unwritable}"
+ if ! ./bin/enable-auth.sh; then
+ echo "${desc}: enable-auth.sh failed once the config was writable" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \
+ conf/rest-server.properties; then
+ echo "${desc}: the repaired run wrote no class to REST" >&2
+ exit 1
+ fi
+ if ! grep -q \
+ '^ authenticator: org\.apache\.hugegraph\.auth\.StandardAuthenticator,$' \
+ conf/gremlin-server.yaml; then
+ echo "${desc}: the repaired run named no class in the yaml" >&2
+ exit 1
+ fi
+ if ! grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \
+ conf/graphs/hugegraph.properties; then
+ echo "${desc}: the repaired run left the graph factory unwrapped" >&2
+ exit 1
+ fi
+ )
+}
+
+if (( host_denies_write )); then
+ for scan in yes no-yamlscan; do
+ case "${scan}" in
+ yes) scan_desc="image layout (yamlscan.awk present)" ;;
+ *) scan_desc="release tarball (no yamlscan.awk)" ;;
+ esac
+ for unwritable in rest-server.properties graphs/hugegraph.properties; do
+ dir="${test_dir}/refused-write-${scan}-${unwritable##*/}"
+ refused_write_tree "${dir}" "${scan}" "${unwritable}"
+ refused_write "${dir}" "${scan_desc}, ${unwritable} read-only" "${unwritable}"
+ done
+ done
+else
+ skip "the read-only enable-auth.sh group -- this host lets a 0444 file be written"
+fi
+
+# ── yaml_auth_state answers about the mapping, not about the text ───────
+# Each case below is a mounted gremlin-server.yaml that a grep-shaped reader
+# calls named while the Gremlin server runs without an authenticator. Reported
+# parity on such a file is how REST ends up enforcing and Gremlin open, so the
+# reader follows the mapping structure instead of the substring.
+yaml_case() {
+ local want="$1" desc="$2" dir
+ shift 2
+ dir="${test_dir}/yaml-$(printf '%s' "${desc}" | tr -c 'A-Za-z0-9' '-')"
+ mkdir -p "${dir}/conf"
+ printf '%s\n' "$@" > "${dir}/conf/gremlin-server.yaml"
+ (
+ cd "${dir}" || exit 1
+ got=$(yaml_auth_state)
+ if [[ "${got}" != "${want}" ]]; then
+ echo "yaml_auth_state: ${desc}: got ${got}, want ${want}" >&2
+ exit 1
+ fi
+ )
+}
+
+# A flow mapping that names nothing, with a commented-out authenticator behind
+# it: the text is there, the key is not.
+yaml_case nameless "flow empty with authenticator in a comment" \
+ 'authentication: {} # authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+# A comment line inside the mapping is not the end of it, so a valid
+# deployment with a note between the keys must not be refused.
+yaml_case named "column-zero comment inside the mapping" \
+ 'authentication:' \
+ '# configured by the operator' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+# config is its own map, so an authenticator under it is the token store
+# configuration and not the server authenticator.
+yaml_case nameless "authenticator nested under config" \
+ 'authentication:' \
+ ' config:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+yaml_case nameless "authenticator nested inside a flow config" \
+ 'authentication: {config: {authenticator: org.apache.hugegraph.auth.StandardAuthenticator}}'
+# The positive cases a wrong reader must keep accepting.
+yaml_case named "plain block child" \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+yaml_case named "direct flow child with siblings" \
+ 'authentication: {config: {tokens: conf/rest-server.properties}, authenticator: org.apache.hugegraph.auth.StandardAuthenticator}'
+yaml_case named "quoted key" \
+ 'authentication:' \
+ ' "authenticator": org.apache.hugegraph.auth.StandardAuthenticator'
+# An authenticator key that names no class leaves the server on
+# AllowAllAuthenticator, so it is the nameless case.
+yaml_case nameless "direct authenticator with no value" \
+ 'authentication:' \
+ ' authenticator:'
+yaml_case nameless "direct authenticator set to null" \
+ 'authentication:' \
+ ' authenticator: null'
+# An `authentication:` belonging to another mapping is not the server's.
+yaml_case none "authentication nested under another key" \
+ 'server:' \
+ ' authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+yaml_case none "no authentication anywhere" \
+ 'host: 8182' \
+ 'port: 1'
+# A sibling key at column zero closes the mapping; an authenticator after it
+# belongs to the sibling, not to authentication.
+yaml_case nameless "sibling key closes the mapping" \
+ 'authentication:' \
+ ' handler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ 'metrics:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+# What snakeyaml resolves to null names no class, and it resolves null
+# case-insensitively, so NULL, Null and nUll are the refusal case just as null
+# is. An explicit !!null says it outright, and an empty quoted scalar is the
+# empty string, for which loadAuthenticator returns null. Reporting any of
+# these as named is the one direction that cannot be forgiven: REST would
+# enforce while Gremlin ran on AllowAllAuthenticator.
+for nullish in 'null' 'NULL' 'Null' 'nUll' '~' '!!null' '!!null ~' '""' "''"; do
+ yaml_case nameless "authenticator set to the null spelling [${nullish}]" \
+ 'authentication:' \
+ " authenticator: ${nullish}"
+done
+# The same values in a flow mapping, where the reader has to reach the value at
+# all: a quoted class name used to arrive empty, which refused a valid mounted
+# config before the server started.
+yaml_case named "unquoted class in a flow mapping" \
+ 'authentication: {authenticator: org.apache.hugegraph.auth.StandardAuthenticator}'
+yaml_case named "double quoted class in a flow mapping" \
+ 'authentication: {authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}'
+yaml_case named "single quoted class in a flow mapping" \
+ "authentication: {authenticator: 'org.apache.hugegraph.auth.StandardAuthenticator'}"
+yaml_case named "double quoted class between flow siblings" \
+ 'authentication: {config: {tokens: conf/rest-server.properties}, authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}'
+# Quoting a scalar makes it a string rather than the null node, so "null" names
+# a class the server fails to load loudly at startup; that is not the silent
+# no-authenticator state the plain spellings above are. Pinned so a later
+# tightening of the null rules cannot move it without saying so here.
+yaml_case named "quoted null is a string, not the null node" \
+ 'authentication:' \
+ ' authenticator: "null"'
+# A nested mapping is still the config map even when the value inside it is
+# quoted, and an empty quoted scalar is the empty string the server reads as no
+# authenticator.
+yaml_case nameless "class nested under a flow config, quoted" \
+ 'authentication: {config: {authenticator: "org.apache.hugegraph.auth.StandardAuthenticator"}}'
+yaml_case nameless "flow value that is an empty quoted string" \
+ 'authentication: {authenticator: ""}'
+# A tag, not the text, decides the type of a scalar, and a type this scanner
+# cannot resolve is refused rather than guessed at.
+yaml_case nameless "explicit str tag, a type this scanner cannot resolve" \
+ 'authentication:' \
+ ' authenticator: !!str org.apache.hugegraph.auth.StandardAuthenticator'
+# Two top-level authentication mappings: Settings.read() resolves the LAST one,
+# so the first must not decide the answer and neither may the file as a whole be
+# refused for carrying two. The base enable-auth.sh appended a block every time
+# conf-bak/ was missing, and conf-bak/ is not on the mounted volume, so a
+# bind-mounted conf/ holds two or three identical ones (#3133, the bug this
+# entrypoint fixes) while TinkerPop 3.5.1 boots that file authenticating.
+# Refusing it stops containers that work; only a LAST mapping that names no
+# class is the unsafe direction, and that still reads nameless here.
+yaml_case nameless "duplicate root authentication mappings" \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \
+ 'metrics:' \
+ ' tokens: conf/tokens' \
+ 'authentication:' \
+ ' handler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler'
+yaml_case named "two identical root mappings name the class" \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler'
+# The empty mapping as the last one is still the mapping the server loads, and
+# it leaves Gremlin on AllowAllAuthenticator beside a configured REST: refused.
+yaml_case nameless "duplicate roots whose last mapping is an empty flow" \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \
+ 'authentication: {}'
+# YAML folds the following deeper line into the scalar, so a key line carrying
+# no value is not the null node: Settings.read() hands over the class, and
+# answering nameless for it stops a container that boots.
+yaml_case named "authenticator value on the following deeper line" \
+ 'authentication:' \
+ ' authenticator:' \
+ ' org.apache.hugegraph.auth.StandardAuthenticator'
+yaml_case named "quoted authenticator value on the following deeper line" \
+ 'authentication:' \
+ ' authenticator:' \
+ ' "org.apache.hugegraph.auth.StandardAuthenticator"'
+# A sibling at the child indentation is not that value, so the key really is
+# empty here and the server reads no authenticator.
+yaml_case nameless "authenticator left empty with a sibling below it" \
+ 'authentication:' \
+ ' authenticator:' \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler'
+# A mapping or collection in that position names no class, and reading one as a
+# class name would arm REST beside a server that dies on the shape.
+yaml_case nameless "authenticator value that is a nested mapping" \
+ 'authentication:' \
+ ' authenticator:' \
+ ' tokens: conf/rest-server.properties'
+# Settings.read() builds the `? authentication` line and its `: ...` value line
+# into the authentication mapping, so answering `none` for that file would start
+# REST open beside a Gremlin that authenticates. The reader does not walk
+# explicit keys, so it refuses rather than guess.
+yaml_case nameless "explicit key root mapping is refused" \
+ '? authentication' \
+ ': authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+# A byte order mark frames the stream; it is not part of the first key, and
+# SnakeYAML resolves `authentication` to the root mapping. Compared
+# byte-for-byte the mark made the key unknown, so the file that does
+# authenticate was reported as having no mapping at all.
+yaml_case named "byte order mark before the root key is not part of it" \
+ $'\xef\xbb\xbf''authentication: {authenticator: com.example.BomAuth}'
+# A root key preceded by a tag, an anchor or an alias still resolves to
+# `authentication` for SnakeYAML, so the server does build the mapping. This
+# reader resolves no node properties, and answering `none` for such a file is
+# the one-sided direction, so it is refused the way an explicit key is.
+yaml_case nameless "tagged root key is refused, not called unauthenticated" \
+ '!!str authentication: {authenticator: com.example.TaggedAuth}'
+yaml_case nameless "anchored root key is refused, not called unauthenticated" \
+ '&k authentication: {authenticator: com.example.AnchoredAuth}'
+yaml_case nameless "aliased root key is refused, not called unauthenticated" \
+ '*a authentication: {authenticator: com.example.AliasedAuth}'
+# Refusing a file because of a node property has a cost, so the refusal covers
+# only the one key this reader is asked about. A property in front of a
+# different root key is an ordinary sibling, and a deployment that carries one
+# beside a working authentication mapping must still be read as named.
+yaml_case named "node property on a different root key stays an ordinary sibling" \
+ '!!str host: 0.0.0.0' \
+ 'authentication:' \
+ ' authenticator: com.example.SiblingAuth'
+yaml_case nameless "tag and anchor together on the root key are refused" \
+ '!!str &k authentication: {authenticator: com.example.TaggedAuth}'
+# A root mapping written indented below a document marker is still the root to
+# Settings.read(). Reporting `none` for it is the opposite mismatch: REST would
+# start open beside a Gremlin that authenticates.
+yaml_case named "indented root mapping after a document marker" \
+ '---' \
+ ' authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+# ...but an `authentication:` nested under a real root key belongs to that key,
+# not to the server: the root is where the document opens, at column 0 here, so
+# the indented one stays invisible exactly as before.
+yaml_case none "nested authentication is not the root even when indented" \
+ 'host: 8182' \
+ 'someFeature:' \
+ ' authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator'
+
+# ── Mounted one-sided config is refused with no PASSWORD ───────────────
+# check_auth_sides used to run only inside the PASSWORD branch, so a mounted
+# rest-server.properties that already carried auth.authenticator and a yaml
+# without a matching mapping was never validated at all: the entrypoint skipped
+# the check, never called enable-auth.sh, and started the server with REST
+# enforcing and Gremlin open. The parity check now runs on every start.
+mounted_dir="${test_dir}/mounted-one-sided"
+mkdir -p "${mounted_dir}/conf/graphs"
+(
+ cd "${mounted_dir}" || exit 1
+ # check_auth_sides reads these two paths, which the entrypoint sets at the
+ # top of a run; this block calls the guard directly, as the other unit
+ # groups here do.
+ REST_SERVER_CONF="./conf/rest-server.properties"
+ GRAPH_CONF="./conf/graphs/hugegraph.properties"
+ printf '%s\n' \
+ 'restserver.url=http://127.0.0.1:8080' \
+ 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ > conf/rest-server.properties
+ printf '%s\n' 'host: 8182' > conf/gremlin-server.yaml
+ printf '%s\n' 'backend=rocksdb' > conf/graphs/hugegraph.properties
+ if check_auth_sides; then
+ echo "check_auth_sides must refuse REST configured with yaml not" >&2
+ exit 1
+ fi
+ # And it accepts the two balanced states, so this is not just a refusal:
+ printf '%s\n' \
+ 'authentication:' \
+ ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \
+ > conf/gremlin-server.yaml
+ check_auth_sides
+ printf '%s\n' 'host: 8182' > conf/gremlin-server.yaml
+ printf '%s\n' \
+ 'restserver.url=http://127.0.0.1:8080' \
+ > conf/rest-server.properties
+ check_auth_sides
+)
+
+# ── Trees the entrypoint hands to enable-auth.sh ───────────────────────
+# check_auth_sides and enable-auth.sh answer one question from two files, so
+# they have to answer it the same way. Every case below is a tree that
+# check_auth_sides ACCEPTS -- which is why the entrypoint goes on to run
+# enable-auth.sh -- and where the old guard here wrote only one side: it asked
+# whether a key or a block was present, while the entrypoint asks whether a
+# value names a class. Those disagree for an `authentication:` nested under
+# another feature and for a defined-but-empty `auth.authenticator`, and the
+# result was REST enforcing StandardAuthenticator beside a Gremlin left on
+# TinkerPop's AllowAllAuthenticator.
+parity_dir="${test_dir}/enable-auth-parity"
+
+# bootstrap : the layout the script ships in, with props.awk beside it.
+bootstrap_tree() {
+ local dir="$1"
+ rm -rf "${dir}"
+ mkdir -p "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \
+ > "${dir}/conf/graphs/hugegraph.properties"
+ : > "${dir}/conf/gremlin-server.yaml"
+ : > "${dir}/conf/rest-server.properties"
+}
+
+# The class the server would read, through the same reader rather than through
+# grep: an appended second definition looks correct to grep and is invisible
+# here, which is the failure these cases are about.
+rest_class() {
+ PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY=auth.authenticator \
+ PROPS_FILE="$1/conf/rest-server.properties" awk -f "${PROPS_AWK}" /dev/null
+}
+
+gremlin_state() {
+ ( cd "$1" && yaml_auth_state )
+}
+
+# accepted_then_both_sides -- refuse to test a tree the
+# entrypoint would never run the script on, then require both sides named.
+# REST_SERVER_CONF is a top-level assignment in docker-entrypoint.sh and this
+# group evals only the functions, so each call has to carry it: unset, the REST
+# side reads as unconfigured whatever the file says, and a one-sided tree would
+# be waved through the very guard being asserted.
+sides_agree() {
+ ( cd "$1" && REST_SERVER_CONF="./conf/rest-server.properties" check_auth_sides )
+}
+
+accepted_then_both_sides() {
+ local desc="$1" dir="$2" state class
+ if ! sides_agree "${dir}" >/dev/null 2>&1; then
+ echo "${desc}: check_auth_sides refused this tree, so enable-auth.sh
+ is never reached -- the case no longer tests what it was written for" >&2
+ exit 1
+ fi
+ ( cd "${dir}" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh ) || {
+ echo "${desc}: enable-auth.sh failed" >&2
+ exit 1
+ }
+ state=$(gremlin_state "${dir}")
+ if [[ "${state}" != "named" ]]; then
+ echo "${desc}: gremlin-server.yaml is ${state}, not named" >&2
+ exit 1
+ fi
+ class=$(rest_class "${dir}")
+ if [[ "${class}" != "org.apache.hugegraph.auth.StandardAuthenticator" ]]; then
+ echo "${desc}: rest-server.properties reads back [${class}]" >&2
+ exit 1
+ fi
+ if [[ "$(grep -c '^auth\.authenticator' "${dir}/conf/rest-server.properties")" != "1" ]]; then
+ echo "${desc}: auth.authenticator has more than one definition" >&2
+ exit 1
+ fi
+ # Parity has to survive the run, not just the files: a tree the script
+ # leaves one-sided must not still pass the guard that let it through.
+ if ! sides_agree "${dir}"; then
+ echo "${desc}: check_auth_sides rejects the tree enable-auth.sh left" >&2
+ exit 1
+ fi
+}
+
+# An `authentication:` that belongs to another mapping is not the server's, so
+# the script owns the whole of the Gremlin side and has to write it.
+bootstrap_tree "${parity_dir}/nested"
+printf '%s\n' 'host: 0.0.0.0' 'someFeature:' ' authentication:' \
+ ' authenticator: com.example.Nested' \
+ > "${parity_dir}/nested/conf/gremlin-server.yaml"
+printf '%s\n' 'restserver.url=http://127.0.0.1:8080' \
+ > "${parity_dir}/nested/conf/rest-server.properties"
+accepted_then_both_sides "nested authentication mapping" "${parity_dir}/nested"
+# The other feature keeps its own block untouched, and the block written for
+# the server is the one at column 0.
+grep -q '^authentication: {$' "${parity_dir}/nested/conf/gremlin-server.yaml"
+grep -q '^ authentication:$' "${parity_dir}/nested/conf/gremlin-server.yaml"
+grep -q '^ authenticator: com\.example\.Nested$' \
+ "${parity_dir}/nested/conf/gremlin-server.yaml"
+
+# Both empty spellings, plus a whitespace value: each parses to the empty
+# string, so each is the unconfigured side and has to be filled in place.
+for empty in 'auth.authenticator=' 'auth.authenticator' 'auth.authenticator= '; do
+ bootstrap_tree "${parity_dir}/empty"
+ printf '%s\n' 'host: 0.0.0.0' > "${parity_dir}/empty/conf/gremlin-server.yaml"
+ printf '%s\n' "${empty}" 'unrelated=true' \
+ > "${parity_dir}/empty/conf/rest-server.properties"
+ accepted_then_both_sides "empty definition [${empty}]" "${parity_dir}/empty"
+ # The placeholder is rewritten where it stood; unrelated content is kept.
+ grep -q '^unrelated=true$' "${parity_dir}/empty/conf/rest-server.properties"
+ [[ "$(head -1 "${parity_dir}/empty/conf/rest-server.properties")" == \
+ 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' ]]
+done
+
+# A value the operator did write is never a default's target. This tree is
+# accepted because both sides already name the same class, and the script has
+# to leave it alone rather than replace it with StandardAuthenticator.
+bootstrap_tree "${parity_dir}/operator"
+printf '%s\n' 'authentication:' ' authenticator: com.example.OperatorAuth' \
+ > "${parity_dir}/operator/conf/gremlin-server.yaml"
+printf '%s\n' 'auth.authenticator=com.example.OperatorAuth' \
+ > "${parity_dir}/operator/conf/rest-server.properties"
+if ! sides_agree "${parity_dir}/operator" >/dev/null 2>&1; then
+ echo "operator class tree: check_auth_sides refused" >&2
+ exit 1
+fi
+( cd "${parity_dir}/operator" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh )
+if [[ "$(rest_class "${parity_dir}/operator")" != "com.example.OperatorAuth" ]]; then
+ echo "operator class must survive the default write: got [$(rest_class "${parity_dir}/operator")]" >&2
+ exit 1
+fi
+if ! sides_agree "${parity_dir}/operator"; then
+ echo "operator class tree lost parity" >&2
+ exit 1
+fi
+
+# ── The Gremlin guard and check_auth_sides have to read the same key ────
+# enable-auth.sh skips the yaml append when the file already carries a
+# top-level authentication mapping. A grep that only knew the bare spelling
+# called an operator's "authentication": block absent and appended a second
+# one beside it, after which the two servers resolve the key in opposite
+# directions while REST keeps the authenticator the operator named.
+top_level_auth_keys() {
+ local file="$1" sq="'"
+ grep -Ec "^[\"${sq}]?authentication[\"${sq}]?[[:blank:]]*:" "${file}"
+}
+
+quoted_key_tree() { #
+ local dir="$1" with_scan="$2"
+ bootstrap_tree "${dir}"
+ [[ "${with_scan}" == "yes" ]] || rm -f "${dir}/yamlscan.awk"
+ printf '%s\n' 'host: 0.0.0.0' '"authentication":' \
+ ' authenticator: com.example.OperatorAuth' > "${dir}/conf/gremlin-server.yaml"
+ printf '%s\n' 'restserver.url=http://127.0.0.1:8080' \
+ 'auth.authenticator=com.example.OperatorAuth' \
+ > "${dir}/conf/rest-server.properties"
+ if ! sides_agree "${dir}" >/dev/null 2>&1; then
+ echo "quoted top-level key (${with_scan}): check_auth_sides refused" >&2
+ exit 1
+ fi
+ ( cd "${dir}" && unset AUTHENTICATOR_CLASS && ./bin/enable-auth.sh ) || {
+ echo "quoted top-level key (${with_scan}): enable-auth.sh failed" >&2
+ exit 1
+ }
+ if [[ "$(top_level_auth_keys "${dir}/conf/gremlin-server.yaml")" != "1" ]]; then
+ echo "quoted top-level key (${with_scan}): the append duplicated the" \
+ "operator block, got $(top_level_auth_keys "${dir}/conf/gremlin-server.yaml")" >&2
+ cat "${dir}/conf/gremlin-server.yaml" >&2
+ exit 1
+ fi
+ if [[ "$(gremlin_state "${dir}")" != "named" ]]; then
+ echo "quoted top-level key (${with_scan}): yaml is no longer named" >&2
+ exit 1
+ fi
+ if [[ "$(rest_class "${dir}")" != "com.example.OperatorAuth" ]]; then
+ echo "quoted top-level key (${with_scan}): REST lost the operator class" >&2
+ exit 1
+ fi
+}
+
+# The image layout, where yamlscan.awk sits in the install home, so the guard
+# asks the same reader the entrypoint does.
+quoted_key_tree "${parity_dir}/quoted-key" yes
+# The plain release tarball carries no yamlscan.awk; the fallback has to keep
+# the same answer for the question it can honestly settle on its own.
+quoted_key_tree "${parity_dir}/quoted-key-tarball" no
+
+# ── A value compared to a literal is the decoded value, not the bytes ───
+# wait-partition.sh is skipped unless ACTUAL_BACKEND reads hstore. The JVM
+# resolves `backend=h\u0073tore` to hstore, so a reader that hands back the
+# on-disk escaping does not see the backend that is actually running, and
+# startup continues before the partitions are assigned.
+escaped_backend="${test_dir}/backend-escape.properties"
+# %s, not the format string: printf resolves \u0073 in a format itself and would
+# write the decoded word, which is the very thing this case has to hand the
+# reader. The next assertion is the guard rail against that happening silently.
+printf '%s\n' 'backend=h\u0073tore' > "${escaped_backend}"
+if [[ "$(tr -d '\n' < "${escaped_backend}")" != 'backend=h\u0073tore' ]]; then
+ echo "fixture must hold the escaped bytes on disk, got [$(cat "${escaped_backend}")]" >&2
+ exit 1
+fi
+if [[ "$(get_prop_encoded backend "${escaped_backend}")" == "hstore" ]]; then
+ echo "the encoded reader is expected to report the on-disk escaping" >&2
+ exit 1
+fi
+if [[ "$(get_prop_decoded backend "${escaped_backend}")" != "hstore" ]]; then
+ echo "decoded read of an escaped backend gave" \
+ "[$(get_prop_decoded backend "${escaped_backend}")]" >&2
+ exit 1
+fi
+# The ordinary spelling is unaffected, so this is not decode-instead-of-read.
+plain_backend="${test_dir}/backend-plain.properties"
+printf 'backend=hstore\n' > "${plain_backend}"
+[[ "$(get_prop_decoded backend "${plain_backend}")" == "hstore" ]]
+[[ "$(get_prop_encoded backend "${plain_backend}")" == "hstore" ]]
+
+# ── The value is judged as the YAML node, not as the bytes after the colon ──
+# An anchor label is not part of the value it names, so `authenticator: &noAuth
+# null` is a mapping whose authenticator resolves to null; an alias points at a
+# node this scanner does not resolve. Both read as a class name if only the
+# first byte is looked at, and the direction that error moves the server in is
+# REST enforcing StandardAuthenticator over a Gremlin left on TinkerPop's
+# AllowAllAuthenticator -- check_auth_sides reports parity and never asks again.
+# An anchor in front of a real class still has to count, or a valid mounted
+# config gets refused before startup.
+anchor_dir="${test_dir}/yaml-anchor"
+mkdir -p "${anchor_dir}/conf"
+(
+ cd "${anchor_dir}" || exit 1
+ state_file="conf/gremlin-server.yaml"
+ want_yaml() {
+ if [[ "$1" != "$2" ]]; then
+ echo "expected yaml state '$1', got '$2'" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' 'authentication:' ' authenticator: &noAuth null' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication:' ' authenticator: &anchorOnly' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication:' ' authenticator: *noAuth' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication: {authenticator: &noAuth null}' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication:' ' authenticator: &cls com.example.Anchored' > "${state_file}"
+ want_yaml named "$(yaml_auth_state)"
+)
+
+# One mapping is answered only once it has been read to the end. Resolving the
+# first direct `authenticator` seen is not a question the scanner can keep: the
+# server takes the last value of a repeated key, and current snakeyaml rejects
+# the document outright instead. `authenticator: com.example.First` followed by
+# `authenticator: null` answered `named` on the first row, which is the config
+# that boots with no Gremlin authenticator while REST has one, in block and in
+# flow form alike.
+dup_dir="${test_dir}/yaml-duplicate"
+mkdir -p "${dup_dir}/conf"
+(
+ cd "${dup_dir}" || exit 1
+ state_file="conf/gremlin-server.yaml"
+ want_yaml() {
+ if [[ "$1" != "$2" ]]; then
+ echo "expected yaml state '$1', got '$2'" >&2
+ exit 1
+ fi
+ }
+
+ printf '%s\n' 'authentication:' ' authenticator: com.example.First' \
+ ' authenticator: null' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication:' ' authenticator: com.example.First' \
+ ' authenticator: com.example.Second' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ printf '%s\n' 'authentication: {authenticator: com.example.A, authenticator: null}' > "${state_file}"
+ want_yaml nameless "$(yaml_auth_state)"
+ # A duplicate under a different key, or one indented into a nested mapping,
+ # is not a second definition of the authenticator and must not refuse a
+ # config the server reads as one clean mapping.
+ printf '%s\n' 'authentication:' ' authenticator: com.example.Only' \
+ ' config: {authenticator: com.example.Nested}' > "${state_file}"
+ want_yaml named "$(yaml_auth_state)"
+ # A single entry is still answered by its own value, however far down the
+ # mapping it sits, so this is last-wins rather than give-up.
+ printf '%s\n' 'authentication:' ' authenticationHandler: org.X' \
+ ' config: {tokens: conf/rest-server.properties}' \
+ ' authenticator: com.example.Late' > "${state_file}"
+ want_yaml named "$(yaml_auth_state)"
+ printf '%s\n' 'authentication:' ' authenticator: com.example.First' \
+ 'other: x' ' authenticator: null' > "${state_file}"
+ want_yaml named "$(yaml_auth_state)"
+)
+
+# A Gremlin config saved with CRLF breaks its lines at CR too: YAML ends a line
+# at CR, LF or CRLF. Carrying the CR into the parse made `authentication:\r`
+# fail the split, so a mapping that does name an authenticator was reported as
+# absent -- and with REST holding no authenticator either, check_auth_sides saw
+# two sides agreeing and started a server that authenticates on Gremlin and
+# leaves REST open.
+#
+# The fixture writes one CR for the host: where awk drops the CR of a CRLF pair
+# in text mode, two are written so that exactly one reaches the record, and the
+# probe below refuses to run the group rather than let it pass on a fixture that
+# quietly became plain LF.
+yaml_cr=$'\r\n'
+if ! (( awk_sees_crlf_cr )); then
+ yaml_cr=$'\r\r\n'
+fi
+crlf_yaml_dir="${test_dir}/yaml-crlf"
+mkdir -p "${crlf_yaml_dir}/conf"
+(
+ cd "${crlf_yaml_dir}" || exit 1
+ state_file="conf/gremlin-server.yaml"
+ REST_SERVER_CONF="./conf/rest-server.properties"
+
+ if [[ "$(printf "probe${yaml_cr}" | awk 'NR == 1 { print length($0) }')" != "6" ]]; then
+ skip "the CRLF gremlin-server.yaml check"
+ exit 0
+ fi
+
+ printf "authentication:%s authenticator: com.example.CrlfAuth%shost: 0.0.0.0%s" \
+ "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" > "${state_file}"
+ [[ "$(yaml_auth_state)" == "named" ]] || {
+ echo "a CRLF mapping that names a class read as [$(yaml_auth_state)]" >&2
+ exit 1
+ }
+
+ printf "authentication:%s tokens: conf/rest-server.properties%s" \
+ "${yaml_cr}" "${yaml_cr}" > "${state_file}"
+ [[ "$(yaml_auth_state)" == "nameless" ]] || {
+ echo "a CRLF mapping without one read as [$(yaml_auth_state)]" >&2
+ exit 1
+ }
+
+ # An empty line never ends a block scalar, and on a CRLF file the record
+ # splits at the CR and leaves exactly such an empty segment after every
+ # line. Closing the scalar there read `authenticator: >-\r` as an empty
+ # value, so a Windows-saved config that does name a class stopped its own
+ # boot while the same file written with LF read `named`.
+ printf "host: 0.0.0.0%sauthentication:%s authenticator: >-%s com.example.CrlfBlockAuth%s" \
+ "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" "${yaml_cr}" > "${state_file}"
+ [[ "$(yaml_auth_state)" == "named" ]] || {
+ echo "a CRLF block-scalar authenticator read as [$(yaml_auth_state)]" >&2
+ exit 1
+ }
+
+ # The one-sided direction, end to end: Gremlin authenticates, REST does not.
+ printf "authentication:%s authenticator: com.example.CrlfAuth%s" \
+ "${yaml_cr}" "${yaml_cr}" > "${state_file}"
+ : > "${REST_SERVER_CONF}"
+ if check_auth_sides; then
+ echo "check_auth_sides must refuse a CRLF yaml that authenticates alone" >&2
+ exit 1
+ fi
+)
+
+# ── props.awk refuses a file it cannot answer a question about ──────────
+# commons-configuration splices an `include` file into the one being read, so
+# `auth.authenticator` can be defined over there and be nowhere in the bytes
+# here. Answering "absent" for it is what starts a REST-open server beside a
+# Gremlin that requires authentication, and the spliced order also decides which
+# of the two definitions wins, so even a key this file does carry cannot be
+# called the effective one. Neither of those is a question this reader can
+# answer, so it stops rather than guessing.
+include_dir="${test_dir}/props-include"
+mkdir -p "${include_dir}/conf"
+(
+ cd "${include_dir}" || exit 1
+ REST_SERVER_CONF="./conf/rest-server.properties"
+ printf '%s\n' 'include=conf/rest-auth.properties' \
+ 'restserver.url=http://127.0.0.1:8080' > "${REST_SERVER_CONF}"
+ printf '%s\n' 'auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
+ > conf/rest-auth.properties
+ printf '%s\n' 'authentication:' ' authenticator: com.example.IncludedAuth' \
+ > conf/gremlin-server.yaml
+
+ if get_prop_encoded restserver.url "${REST_SERVER_CONF}" >/dev/null 2>&1; then
+ echo "a read of a file with an include must refuse, not answer" >&2
+ exit 1
+ fi
+ # A refused write leaves the config exactly as it stood: no second
+ # definition is appended beside one the server may resolve the other way.
+ if set_prop auth.authenticator com.example.Written "${REST_SERVER_CONF}" 2>/dev/null; then
+ echo "a set must refuse to write into an including file" >&2
+ exit 1
+ fi
+ grep -Fxq 'include=conf/rest-auth.properties' "${REST_SERVER_CONF}"
+ grep -Fxq 'restserver.url=http://127.0.0.1:8080' "${REST_SERVER_CONF}"
+
+ # The operator has to be told which question could not be read, rather than
+ # being sent to the other side of the parity check.
+ if check_auth_sides 2>/dev/null; then
+ echo "check_auth_sides must not boot on an unreadable side" >&2
+ exit 1
+ fi
+ # Captured rather than piped: pipefail makes a refused check the status of
+ # the pipeline no matter what grep matched, so the message would have to be
+ # asserted through a variable.
+ inc_out=$(check_auth_sides 2>&1 || true)
+ case "${inc_out}" in
+ *"cannot read auth.authenticator"*) ;;
+ *) echo "check_auth_sides must say the REST side could not be read, got [${inc_out}]" >&2
+ exit 1 ;;
+ esac
+
+ # Commons configuration 2 matches the directive name case-insensitively and
+ # carries a second spelling, `includeOptional`, that splices a file in the
+ # same way. Refusing only the exact lowercase `include` let `INCLUDE=` or
+ # `includeOptional=` through as an ordinary property, so the entrypoint read
+ # and rewrote a file whose effective authenticator lived over there -- the
+ # same wrong direction the plain include is refused for.
+ for directive in 'include' 'INCLUDE' 'Include' \
+ 'includeOptional' 'includeoptional' 'IncludeOptional' 'INCLUDEOPTIONAL'; do
+ printf '%s\n' "${directive}=conf/rest-auth.properties" \
+ 'restserver.url=http://127.0.0.1:8080' > "${REST_SERVER_CONF}"
+ if get_prop_encoded restserver.url "${REST_SERVER_CONF}" >/dev/null 2>&1; then
+ echo "a read must refuse the include spelling [${directive}]" >&2
+ exit 1
+ fi
+ if PROPS_MODE=set PROPS_KEY=auth.authenticator \
+ PROPS_VALUE_ENCODED=com.example.Written PROPS_FILE="${REST_SERVER_CONF}" \
+ awk -f "${PROPS_AWK}" /dev/null 2>/dev/null; then
+ echo "a set must refuse the include spelling [${directive}]" >&2
+ exit 1
+ fi
+ done
+
+ # Controls: `include` is the whole key, and only a live directive counts.
+ printf '%s\n' 'included.filter=1' 'auth.authenticator=com.example.Plain' \
+ > "${REST_SERVER_CONF}"
+ [[ "$(get_prop_encoded auth.authenticator "${REST_SERVER_CONF}")" == \
+ "com.example.Plain" ]]
+ printf '%s\n' '#include=conf/rest-auth.properties' \
+ 'auth.authenticator=com.example.Comment' > "${REST_SERVER_CONF}"
+ [[ "$(get_prop_encoded auth.authenticator "${REST_SERVER_CONF}")" == \
+ "com.example.Comment" ]]
+)
+
+# ── Temporary files are private and cannot be arranged in advance ───────
+# A predictable `.tmp` is a name anyone with write access to a mounted
+# conf directory can use first, and neither the pre-creating redirection nor
+# awk's `>` checks what is behind it: run as root in the default image, the
+# copy-back would write auth.admin_pa through a planted symlink into whatever
+# file that link named. The same holds for the `.bak` snapshot. Both names now
+# come from an exclusive create, so there is nothing to arrange and nothing to
+# follow.
+planted_dir="${test_dir}/planted-temps"
+mkdir -p "${planted_dir}"
+planted="${planted_dir}/rest-server.properties"
+printf '%s\n' 'auth.authenticator=com.example.Old' > "${planted}"
+printf 'NOT-YOURS-TMP\n' > "${planted}.tmp"
+printf 'NOT-YOURS-BAK\n' > "${planted}.bak"
+set_prop auth.authenticator com.example.New "${planted}"
+grep -Fxq 'auth.authenticator=com.example.New' "${planted}"
+grep -Fxq 'NOT-YOURS-TMP' "${planted}.tmp" || {
+ echo "a file already named .tmp was written through" >&2
+ exit 1
+}
+grep -Fxq 'NOT-YOURS-BAK' "${planted}.bak" || {
+ echo "a file already named .bak was written through" >&2
+ exit 1
+}
+if [[ -n "$(find "${planted_dir}" \( -name '*.tmp.*' -o -name '*.bak.*' \) 2>/dev/null)" ]]; then
+ echo "the staged rewrite left a temporary file behind" >&2
+ exit 1
+fi
+if (( host_keeps_symlink )); then
+ victim="${planted_dir}/victim.txt"
+ printf 'VICTIM\n' > "${victim}"
+ rm -f "${planted}.tmp"
+ ln -s "${victim}" "${planted}.tmp"
+ set_prop auth.authenticator com.example.Linked "${planted}"
+ grep -Fxq 'VICTIM' "${victim}" || {
+ echo "the entrypoint wrote credentials through a symlinked temp file" >&2
+ exit 1
+ }
+ grep -Fxq 'auth.authenticator=com.example.Linked' "${planted}"
+else
+ skip "the symlinked-temp-file check"
+fi
+
+# ── A trailing blank on gremlin.graph must not leave the graph unwrapped ──
+# commons-configuration trims the line before it resolves the class, so a
+# mounted `gremlin.graph=org.apache.hugegraph.HugeFactory ` opens the graph
+# through the plain factory exactly as the same line without the blanks does.
+# Comparing the untrimmed bytes answered "not HugeFactory" and left
+# HugeFactoryAuthProxy out of an otherwise fully authenticated tree, which
+# GraphManager only warns about. (java.util.Properties by itself keeps the
+# blanks -- measured against JDK 17 -- which is why the reader hands them back.)
+run_enable_auth() {
+ local dir="$1" graph_line="$2"
+
+ mkdir -p "${dir}/bin" "${dir}/conf/graphs"
+ install_enable_auth "${dir}"
+ printf '%s\n' 'host: 0.0.0.0' > "${dir}/conf/gremlin-server.yaml"
+ printf '%s\n' 'restserver.url=http://127.0.0.1:8080' > "${dir}/conf/rest-server.properties"
+ printf '%s\n' "${graph_line}" > "${dir}/conf/graphs/hugegraph.properties"
+ if ! ( cd "${dir}" && ./bin/enable-auth.sh ); then
+ echo "enable-auth.sh failed for [${graph_line}]" >&2
+ exit 1
+ fi
+}
+for blank in ' ' '\ '; do
+ blank_dir="${test_dir}/factory-blank-${blank//\\/esc}"
+ run_enable_auth "${blank_dir}" "gremlin.graph=org.apache.hugegraph.HugeFactory${blank}"
+ grep -q '^gremlin\.graph=org\.apache\.hugegraph\.auth\.HugeFactoryAuthProxy$' \
+ "${blank_dir}/conf/graphs/hugegraph.properties" || {
+ echo "a trailing blank (${blank}) left the graph outside the auth proxy" >&2
+ exit 1
+ }
+done
+# A factory that is not HugeFactory is left exactly as mounted, blanks and all.
+foreign_dir="${test_dir}/factory-foreign"
+run_enable_auth "${foreign_dir}" 'gremlin.graph=com.example.OtherFactory '
+if grep -q 'HugeFactoryAuthProxy' "${foreign_dir}/conf/graphs/hugegraph.properties"; then
+ echo "enable-auth.sh rewrote a factory it does not own" >&2
+ exit 1
+fi
+
+# ── A written value must not end where a trim turns it into a continuation ─
+# encode_prop_value wrote a space as `\ `. Read back by java.util.Properties
+# that is a space, but commons-configuration right-trims the physical line first
+# and then asks whether it continues, so `abc\ ` became `abc\` and swallowed the
+# line under it -- a password ending in a space ate the `auth.authenticator`
+# written below it, and the guards reported a config that was already broken.
+# \u0020 decodes to the same space in both readers and leaves nothing to trim.
+space_file="${test_dir}/encoded-trailing-space"
+printf '%s\n' 'auth.admin_pa=placeholder' \
+ 'auth.authenticator=com.example.Below' > "${space_file}"
+[[ "$(encode_prop_value 'abc ')" == 'abc\u0020' ]] || {
+ echo "a space is still encoded in a form a trim can cut: [$(encode_prop_value 'abc ')]" >&2
+ exit 1
+}
+set_prop auth.admin_pa 'abc ' "${space_file}"
+grep -Fxq 'auth.admin_pa=abc\u0020' "${space_file}" || {
+ echo "written on disk as [$(sed -n 's/^auth\.admin_pa=//p' "${space_file}")]" >&2
+ exit 1
+}
+[[ "$(get_prop_decoded auth.admin_pa "${space_file}")" == "abc " ]]
+# The property under a value that ends in a space is still its own property.
+[[ "$(get_prop_decoded auth.authenticator "${space_file}")" == "com.example.Below" ]]
+grep -Fxq 'auth.authenticator=com.example.Below' "${space_file}"
+# The guard judges the line the way the server sees it, so an encoded value
+# built somewhere else cannot carry the hazard in through the back door.
+if set_prop_encoded auth.token_secret 'abc\ ' "${space_file}" 2>/dev/null; then
+ echo "a value ending in backslash+blank would swallow the next line" >&2
+ exit 1
+fi
+if set_prop_encoded auth.token_secret 'abc\\\ ' "${space_file}" 2>/dev/null; then
+ echo "a value ending in an odd run of backslashes before a blank was accepted" >&2
+ exit 1
+fi
+# An even run is a literal backslash and continues nothing.
+set_prop_encoded auth.token_secret 'abc\\ ' "${space_file}"
+[[ "$(get_prop_decoded auth.token_secret "${space_file}")" == 'abc\ ' ]]
+grep -Fxq 'auth.admin_pa=abc\u0020' "${space_file}"
+
+# ── No helper hands chmod an argument it means as an option ─────────────
+# `chmod 600 -- file` is GNU-only: BSD chmod reads `--` as the file name after
+# the mode and fails, which on macOS left props.awk unable to back up the config
+# it was about to rewrite. Nothing needs the separator here -- every path goes
+# through shquote, so an argument can only start at a quote byte -- and the two
+# calls it was written for are gone now that the staged files are created 0600
+# by mktemp.
+dash_dir="${test_dir}/dash-named-config"
+mkdir -p "${dash_dir}"
+printf '%s\n' 'auth.authenticator=com.example.Old' > "${dash_dir}/-config.properties"
+set_prop auth.authenticator com.example.New "${dash_dir}/-config.properties"
+grep -Fxq 'auth.authenticator=com.example.New' "${dash_dir}/-config.properties"
+[[ "$(get_prop_encoded auth.authenticator "${dash_dir}/-config.properties")" == \
+ "com.example.New" ]]
+if grep -Eq 'chmod[^#]*--' "${PROPS_AWK}"; then
+ echo "props.awk still passes -- to chmod, which BSD chmod reads as a file" >&2
+ grep -En 'chmod[^#]*--' "${PROPS_AWK}" >&2
+ exit 1
+fi
diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk
new file mode 100644
index 0000000000..107b94ca68
--- /dev/null
+++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk
@@ -0,0 +1,794 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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
+#
+# http://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.
+#
+# yamlscan.awk -- does the top-level `authentication` mapping of a Gremlin
+# server YAML file name an authenticator? Prints exactly one of:
+#
+# none there is no top-level authentication mapping
+# nameless the mapping exists but names no authenticator class
+# named the mapping names an authenticator class
+#
+# The entrypoint asks this one question to decide whether
+# rest-server.properties and gremlin-server.yaml configure authentication
+# together. Getting it wrong toward "named" is how REST ends up enforcing
+# StandardAuthenticator while Gremlin silently falls back to TinkerPop
+# AllowAllAuthenticator, so the answer has to follow the same structure
+# snakeyaml hands to the server, within the subset of YAML that shipped and
+# mounted configs use:
+#
+# 1. the mapping must sit at the indentation of the document root -- an
+# `authentication:` nested under some other key belongs to that feature,
+# not to the Gremlin server, while a root mapping written indented below a
+# document marker still counts because the server reads it as the root;
+# 2. only a direct child `authenticator` counts -- a class reached through
+# `authentication.config`, or through any other nested mapping, is not
+# the server authenticator, because TinkerPop keeps `config` as its own
+# map;
+# 3. `#` outside quotes starts a comment: text behind one is not content,
+# and a comment-only line is neither a child nor the end of the mapping;
+# 4. in a flow mapping the key must sit at depth one between the braces, so
+# `{authenticator: X}` names a class while `{config: {authenticator: X}}`
+# does not;
+# 5. a direct `authenticator` whose value is empty, `null` or `~` names no
+# class -- the server reads the key, gets nothing and leaves
+# authentication off, which is the nameless case that must be refused;
+# 6. a mapping is read to its end before it is answered, because the server
+# sees the whole node: a direct `authenticator` defined twice is refused
+# rather than settled by whoever met it first;
+# 6b. the file is read to its end, not to the first mapping, because two
+# top-level `authentication` mappings resolve to the LAST one. The
+# answer therefore comes from the last mapping, which is the mapping the
+# server loads: the base enable-auth.sh appended a block every time
+# conf-bak/ was missing, so a bind-mounted conf/ carries two or three
+# identical ones (#3133, the bug this entrypoint fixes) and boots as an
+# authenticating server. Refusing that file would stop a container that
+# works, while an empty or class-less LAST mapping is still `nameless` and
+# still stops the boot, which is the direction that matters;
+# 7. YAML ends a line at CR, LF or CRLF, so a CR that has survived the
+# comment being stripped is line noise, not part of a key or a value.
+# Reading `authentication:\r` as no key at all reported a Gremlin mapping
+# that names a class as absent, which is the direction that leaves REST
+# open while Gremlin authenticates.
+# 8. a child value may open a flow collection or a quoted scalar that its own
+# line does not close, and flow content ignores indentation -- so every
+# line until it closes belongs to the nested node rather than to the
+# authentication mapping. Counting one of those continuation lines as a
+# direct child answered `named` for a file whose authenticator sits inside
+# `authentication.config`, which is the direction that leaves REST
+# enforcing beside a Gremlin on AllowAllAuthenticator;
+# 8b. the mapping itself may be a flow collection that opens behind an anchor
+# (`authentication: &auth {authenticator: X}`) or on its first child line
+# rather than on the key line. Settings.read() loads both, so answering
+# `nameless` for them stops a boot that works.
+# 9. a child key may carry no value on its own line and leave the scalar to
+# the following, deeper line, which YAML folds into that value. Settings
+# reads
+# authenticator:
+# org.apache.hugegraph.auth.StandardAuthenticator
+# as a class name, so answering `nameless` for that spelling stops a
+# container that boots. A value there that is a nested mapping or
+# collection names no class and is refused instead of read as one.
+# 10. an explicit key (`? authentication` with its `: ...` value line) is a
+# root mapping this reader does not walk. Settings.read() still builds
+# the authentication mapping from it, so answering `none` would start REST
+# open beside a Gremlin that authenticates; it is refused.
+# 11. a UTF-8 byte order mark before the first line is stream framing, not
+# part of the key: SnakeYAML skips it, so `authentication` is the
+# root mapping. Carrying the mark into the key comparison missed it and
+# answered `none`, which is the unsafe direction, so it is dropped from the
+# first record.
+# 12. a root key preceded by a node property (`!!str authentication`, `&k
+# authentication`, `*a authentication`) resolves to `authentication` for
+# SnakeYAML and does open the Gremlin mapping. Resolving node properties
+# is not what this reader does, so like rule 10 it is refused rather than
+# called unauthenticated. Only a property in front of that one key is
+# refused: `&defaults handler_pool:` or `!!str host:` is an ordinary root
+# sibling whose own mapping the server never reads as authentication, and
+# refusing a file because of one would stop a container that boots.
+# 13. a block scalar is not ended by an empty line -- YAML keeps an empty line
+# inside it as content. Splitting a CRLF record at the CR (rule 7) leaves
+# an empty segment behind, and closing the scalar there read
+# `authenticator: >-\r` as an empty value, so a Windows-saved config that
+# does name a class stopped its own boot.
+#
+# Quote characters come from sprintf so this file holds no literal apostrophe:
+# an awk program written into a single-quoted shell string breaks on one, and
+# that has cost this repo twice already.
+
+function apos() { return sprintf("%c", 39) }
+function dquo() { return sprintf("%c", 34) }
+
+function ltrim(s) { sub(/^[ \t]+/, "", s); return s }
+function rtrim(s) { sub(/[ \t]+$/, "", s); return s }
+function trim(s) { return rtrim(ltrim(s)) }
+
+function is_quote(c) { return c == apos() || c == dquo() }
+
+# Drop a leading UTF-8 byte order mark from a record. A multibyte-aware awk
+# hands the mark over as the single character U+FEFF and a byte-oriented one as
+# the three bytes EF BB BF, so both spellings are tried and only the one this
+# reader actually produced can match -- the guards on length keep a reader that
+# produced neither from stripping a byte off a legitimate first key.
+function strip_bom(s, mark) {
+ mark = sprintf("%c%c%c", 239, 187, 191)
+ if (length(mark) == 3 && substr(s, 1, 3) == mark) return substr(s, 4)
+ mark = sprintf("%c", 65279)
+ if (length(mark) == 1 && substr(s, 1, 1) == mark) return substr(s, 2)
+ return s
+}
+
+# Remove an unquoted trailing comment together with the whitespace that has to
+# precede the `#` for it to be a comment rather than part of a scalar.
+function strip_comment(s, i, n, c, q, prev) {
+ q = ""
+ prev = ""
+ n = length(s)
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (q != "") {
+ if (c == q) q = ""
+ } else if (is_quote(c)) {
+ q = c
+ } else if (c == "#" && (prev == "" || prev == " " || prev == "\t")) {
+ return rtrim(substr(s, 1, i - 1))
+ }
+ prev = c
+ }
+ return s
+}
+
+# How many whitespace characters open the line, i.e. its block nesting level.
+function indent_of(s, i, n, c) {
+ n = length(s)
+ i = 1
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c != " " && c != "\t") break
+ i++
+ }
+ return i - 1
+}
+
+# Hex without strtonum, which is not POSIX awk.
+function hexval(h, i, n, c, v) {
+ v = 0
+ n = length(h)
+ for (i = 1; i <= n; i++) {
+ c = substr(h, i, 1)
+ if (c >= "0" && c <= "9") v = v * 16 + (c - 0)
+ else if (c == "a" || c == "A") v = v * 16 + 10
+ else if (c == "b" || c == "B") v = v * 16 + 11
+ else if (c == "c" || c == "C") v = v * 16 + 12
+ else if (c == "d" || c == "D") v = v * 16 + 13
+ else if (c == "e" || c == "E") v = v * 16 + 14
+ else if (c == "f" || c == "F") v = v * 16 + 15
+ else return -1
+ }
+ return v
+}
+
+# Resolve the escapes a double quoted scalar carries, which snakeyaml does
+# before the text ever becomes a key. `"\u0061uthentication"` is the
+# authentication key, and `authentic\u0061tion` is the same key spelled out.
+# An escape this cannot resolve sets UNRESOLVED instead of being skipped: being
+# wrong about a key toward "absent" is what leaves REST open beside an
+# authenticating Gremlin, so an unresolved form has to be refused.
+function unescape(s, out, i, n, c, h, k, v) {
+ n = length(s)
+ out = ""
+ i = 1
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c != "\\") { out = out c; i++; continue }
+ i++
+ if (i > n) { UNRESOLVED = 1; return s }
+ c = substr(s, i, 1)
+ if (c == "u" || c == "U") k = (c == "u" ? 4 : 8)
+ else if (c == "x") k = 2
+ else k = 0
+ if (k > 0) {
+ h = substr(s, i + 1, k)
+ v = (length(h) == k ? hexval(h) : -1)
+ if (v < 0) { UNRESOLVED = 1; return s }
+ out = out sprintf("%c", v)
+ i = i + k + 1
+ continue
+ }
+ if (c == "0") { out = out sprintf("%c", 0); i++; continue }
+ if (c == "a") { out = out sprintf("%c", 7); i++; continue }
+ if (c == "e") { out = out sprintf("%c", 27); i++; continue }
+ if (c == "N") { out = out sprintf("%c", 133); i++; continue }
+ if (c == "L") { out = out sprintf("%c", 8232); i++; continue }
+ if (c == "P") { out = out sprintf("%c", 8233); i++; continue }
+ if (c == "b") { out = out "\b"; i++; continue }
+ if (c == "t") { out = out "\t"; i++; continue }
+ if (c == "n") { out = out "\n"; i++; continue }
+ if (c == "v") { out = out "\v"; i++; continue }
+ if (c == "f") { out = out "\f"; i++; continue }
+ if (c == "r") { out = out "\r"; i++; continue }
+ if (c == " " || c == dquo() || c == "\\" || c == "/") {
+ out = out c
+ i++
+ continue
+ }
+ UNRESOLVED = 1
+ return s
+ }
+ return out
+}
+
+# One layer of matching quotes off a key or scalar.
+function unquote(s, f, body) {
+ s = trim(s)
+ if (length(s) >= 2) {
+ f = substr(s, 1, 1)
+ if ((f == apos() || f == dquo()) && substr(s, length(s), 1) == f) {
+ body = substr(s, 2, length(s) - 2)
+ return (f == dquo() ? unescape(body) : body)
+ }
+ }
+ return s
+}
+
+# Split `name: value` at the first colon outside quotes that is followed by end
+# of line or a space, which is what makes a colon inside `http://host` part of
+# the scalar. Results go to K_TXT / V_TXT because awk returns one value.
+function split_pair(s, i, n, c, q) {
+ q = ""
+ n = length(s)
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (q != "") {
+ if (c == q) q = ""
+ continue
+ }
+ if (is_quote(c)) { q = c; continue }
+ if (c != ":") continue
+ if (i == n || substr(s, i + 1, 1) ~ /^[ \t]/) {
+ K_TXT = rtrim(substr(s, 1, i - 1))
+ V_TXT = ltrim(substr(s, i + 1))
+ return 1
+ }
+ }
+ return 0
+}
+
+function names_authenticator(k) { return unquote(k) == "authenticator" }
+
+# An authenticator entry only counts when it actually names a class, and the
+# answer has to be what snakeyaml hands the server rather than what the bytes
+# look like. The unsafe direction is `named` for a config that leaves Gremlin
+# on AllowAllAuthenticator while REST enforces, so anything this scanner cannot
+# resolve to a class is refused instead of guessed at:
+#
+# - a plain scalar that resolves to null in any spelling, and YAML resolves
+# null case-insensitively (null, Null, NULL, nUll) as well as to ~, names
+# no class;
+# - a leading `!` makes the tag, not the text, decide the type: !!null is the
+# explicit spelling of empty and every other tag is a type not resolvable
+# here, so neither counts;
+# - a quoted scalar is a string and never null, but `""` and the empty single
+# quoted form are the empty string, and loadAuthenticator("") returns null,
+# which is the same no-authenticator state;
+# - `&label value` is an anchor: the label is not part of the value, so the
+# text after it decides, and `&label` alone anchors an empty node, which is
+# the explicit spelling of null;
+# - `*label` is an alias whose class lives in another node. This scanner
+# does not resolve nodes, so an alias is refused rather than read as a
+# class name -- `authenticator: &noAuth null` is a valid document whose
+# value is null, and calling it named is the exact mistake this guards.
+# - an unterminated quote is not a scalar at all.
+function names_class(v, first, last, body, rest) {
+ v = trim(v)
+ if (v == "") return 0
+ first = substr(v, 1, 1)
+ if (first == "!") return 0
+ if (first == "*") return 0
+ if (first == "&") {
+ rest = trim(substr(v, 2))
+ sub(/^[^ \t]*/, "", rest)
+ return names_class(trim(rest))
+ }
+ if (first == apos() || first == dquo()) {
+ if (length(v) < 2) return 0
+ last = substr(v, length(v), 1)
+ if (last != first) return 0
+ body = trim(substr(v, 2, length(v) - 2))
+ return body != ""
+ }
+ if (v == "~") return 0
+ if (tolower(v) == "null") return 0
+ return 1
+}
+
+# The answer for the mapping read so far, for both the block and the flow form.
+# AUTH_SEEN counts direct `authenticator` children and AUTH_NAMED remembers
+# whether the last one named a class. Both describe the LAST top-level
+# `authentication` mapping, because handle_line() clears them when a later one
+# opens and that is the node Settings.read() loads. A key defined twice inside
+# that mapping has no answer this scanner can give honestly: snakeyaml either
+# keeps the last value or, with unique keys enforced, rejects the document and
+# the server never starts. Either way the operator has to be told which line to
+# fix, so the duplicate is reported on stderr and the mapping is refused through
+# the nameless state, which check_auth_sides stops the boot on and
+# enable-auth.sh will not append beside.
+function auth_state( msg) {
+ if (AUTH_SEEN > 1) {
+ msg = "yamlscan.awk: a mapping with " AUTH_SEEN " direct authenticator entries"
+ print msg > "/dev/stderr"
+ print "cannot be answered here: the server takes the last one, or rejects the file." > "/dev/stderr"
+ print "Remove the duplicate authenticator entry from gremlin-server.yaml." > "/dev/stderr"
+ return "nameless"
+ }
+ if (AUTH_SEEN == 1 && AUTH_NAMED) return "named"
+ # A class-less mapping is normally a hand edit, but the last of several
+ # duplicated root mappings is what the base enable-auth.sh leaves behind,
+ # and there "add an authenticator entry" is the wrong advice: the fix is to
+ # drop the extra mapping, so say which one was read.
+ if (AUTH_BLOCKS > 1) {
+ msg = "yamlscan.awk: " AUTH_BLOCKS " top-level authentication mappings; read the last"
+ print msg > "/dev/stderr"
+ print "one, and it names no authenticator. Keep a single mapping that does." > "/dev/stderr"
+ }
+ return "nameless"
+}
+
+# Feed one line of a flow collection to the brace scanner. DEPTH counts open
+# collections; keys and values are only read at depth one, which is what makes
+# a nested mapping under `config` invisible to it. A direct authenticator seen
+# at depth one is recorded for auth_state. Returns 1 once the outermost
+# collection has closed.
+function scan_flow(s, i, n, c, q, esc) {
+ n = length(s)
+ q = ""
+ esc = 0
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (q != "") {
+ # Every byte inside the quotes belongs to the scalar, delimiters
+ # included; unquote and names_class take the quotes off. Dropping
+ # the value here is what made {authenticator: "org.A"} read as
+ # nameless and refuse a valid mounted config. A backslash escapes
+ # the next byte in a double quoted scalar only -- in a single
+ # quoted one the way out is a doubled quote, which this loop
+ # already gets right because the first one closes and the next
+ # reopens, and the pair still counts as content.
+ if (FST == "key") CUR = CUR c
+ else if (FST == "val") CUR_VAL = CUR_VAL c
+ if (esc) esc = 0
+ else if (q == dquo() && c == "\\") esc = 1
+ else if (c == q) q = ""
+ continue
+ }
+ if (is_quote(c)) {
+ q = c
+ if (FST == "key") CUR = CUR c
+ else if (FST == "val") CUR_VAL = CUR_VAL c
+ continue
+ }
+ if (c == "{" || c == "[") {
+ DEPTH++
+ CUR = ""
+ # Past depth one the whole entry is nested content and is skipped,
+ # including an authenticator key inside it.
+ FST = (DEPTH == 1 ? "key" : "skip")
+ continue
+ }
+ if (c == "}" || c == "]") {
+ if (DEPTH == 1 && FST == "val") commit_val()
+ DEPTH--
+ CUR = ""
+ if (DEPTH == 0) { FST = "key"; return 1 }
+ FST = "skip"
+ continue
+ }
+ if (c == "\r") continue
+ if (DEPTH != 1) continue
+ if (c == ":") {
+ if (FST == "key") {
+ CUR_KEY = CUR
+ CUR_VAL = ""
+ FST = "val"
+ }
+ CUR = ""
+ continue
+ }
+ if (c == ",") {
+ if (FST == "val") commit_val()
+ FST = "key"
+ CUR = ""
+ continue
+ }
+ if (c == " " || c == "\t") {
+ # A space ends an unquoted key but never carries a value byte.
+ continue
+ }
+ if (FST == "key") CUR = CUR c
+ else if (FST == "val") CUR_VAL = CUR_VAL c
+ }
+ return 0
+}
+
+# Close out the depth-one entry that was being read when a `,` or `}` arrived.
+function commit_val( k) {
+ k = CUR_KEY
+ if (names_authenticator(k)) {
+ AUTH_SEEN++
+ AUTH_NAMED = names_class(CUR_VAL)
+ }
+}
+
+# Refuse a document whose shape this reader cannot resolve, through the same
+# nameless state that makes check_auth_sides stop the boot and that
+# duplicate_root() uses above. Guessing `none` here is the unsafe answer: it
+# tells the entrypoint that Gremlin configures nothing, so an operator whose
+# mounted file does authenticate gets REST started open beside it.
+function refuse(what, msg) {
+ msg = "yamlscan.awk: " what
+ print msg > "/dev/stderr"
+ print "cannot be classified by this reader, so it is refused rather than" > "/dev/stderr"
+ print "called unauthenticated. Rewrite gremlin-server.yaml in the plain" > "/dev/stderr"
+ print "block form, or fix the spelling above, then restart." > "/dev/stderr"
+ return "nameless"
+}
+
+# `|` and `>` open a block scalar, whose content is on the following, deeper
+# lines rather than on the key line. Reading the indicator itself as the value
+# answered `named` for `authenticator: |` with nothing behind it, and snakeyaml
+# hands the server an empty string there, which is no authenticator at all.
+function is_block_scalar(v) {
+ v = trim(v)
+ if (v == "") return 0
+ if (substr(v, 1, 1) != "|" && substr(v, 1, 1) != ">") return 0
+ return substr(v, 2) ~ /^[0-9]*[-+]?$/
+}
+
+# `&label` in front of a value is an anchor and not part of the value, so a
+# flow mapping written `authentication: &auth {authenticator: X}` opens with the
+# brace exactly as the unanchored spelling does. names_class() already reads
+# anchors this way on a scalar.
+function unanchor(s) {
+ s = trim(s)
+ if (substr(s, 1, 1) != "&") return s
+ sub(/^&[^ \t]*/, "", s)
+ return trim(s)
+}
+
+# One or more node properties may open a key -- `!!str`, `&label`, `*alias` --
+# and SnakeYAML resolves them off the key rather than reading them as part of its
+# text, so `&k authentication` is the authentication key. Stripping them lets
+# the root comparison below say which key the server is actually building.
+function strip_node_props(s, f) {
+ s = trim(s)
+ while (1) {
+ f = substr(s, 1, 1)
+ if (f != "!" && f != "&" && f != "*") break
+ sub(/^[^ \t]+[ \t]*/, "", s)
+ }
+ return s
+}
+
+# Walk the bytes of a line that sits inside a flow collection or a quoted
+# scalar opened on an earlier line, updating SP (collections still open) and SQ
+# (the quote still open). Braces inside a quoted scalar are text, and a
+# backslash escapes the next byte of a double quoted scalar only.
+function flow_span(s, i, n, c) {
+ n = length(s)
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (SQ != "") {
+ if (ESC) ESC = 0
+ else if (SQ == dquo() && c == "\\") ESC = 1
+ else if (c == SQ) SQ = ""
+ continue
+ }
+ if (is_quote(c)) { SQ = c; continue }
+ if (c == "{") SP++
+ else if (c == "[") SP++
+ else if (c == "}") SP--
+ else if (c == "]") SP--
+ }
+}
+
+# A child value that begins with a brace or bracket, or with a quote this line
+# never closes, carries on below rather than ending here. Only an opening byte
+# at the very start counts: a plain scalar may hold a brace anywhere else in it
+# and still be complete on its own line.
+function opens_child_span(v, s, first) {
+ s = unanchor(v)
+ if (s == "") return
+ first = substr(s, 1, 1)
+ if (first != "{" && first != "[" && !is_quote(first)) return
+ flow_span(s)
+}
+
+BEGIN {
+ DEPTH = 0
+ FST = "key"
+ CUR = ""
+ CUR_KEY = ""
+ CUR_VAL = ""
+ AUTH_SEEN = 0
+ AUTH_NAMED = 0
+ ROOT_IND = -1
+ AUTH_BLOCKS = 0
+ in_auth = 0
+ child = -1
+ flow = 0
+ RESULT = ""
+ UNRESOLVED = 0
+ ROOT_FLOW = 0
+ BLOCK = 0
+ BLOCK_IND = 0
+ BLOCK_TXT = ""
+ SP = 0
+ SQ = ""
+ ESC = 0
+ PENDING = 0
+ PENDING_IND = 0
+ EXPLICIT = 0
+ ROOT_PROP = 0
+ NESTED_VAL = 0
+}
+
+# Close out the block scalar whose lines were being collected.
+function finish_block() {
+ BLOCK = 0
+ AUTH_NAMED = names_class(BLOCK_TXT)
+ BLOCK_TXT = ""
+}
+
+function handle_line(raw, line, ind, v, t, f) {
+ if (BLOCK) {
+ # An empty line is content inside a block scalar, never its end. On a
+ # CRLF file the record splits at the CR and leaves exactly such an empty
+ # segment after every line, so closing here ended `authenticator: >-`
+ # before the class line below it had been read and a config that does
+ # name a class was refused.
+ if (trim(raw) == "") return
+ # Deeper than the key means the line is still scalar content; anything
+ # else ends the scalar and is ordinary content again.
+ if (indent_of(raw) > BLOCK_IND) {
+ if (BLOCK_TXT != "") BLOCK_TXT = BLOCK_TXT " "
+ BLOCK_TXT = BLOCK_TXT trim(strip_comment(raw))
+ return
+ }
+ finish_block()
+ }
+
+ # A CR that survived the comment being stripped is line noise, not part of
+ # a key or a value.
+ line = strip_comment(raw)
+ sub(/[ \t]+$/, "", line)
+ if (trim(line) == "") return
+
+ ind = indent_of(line)
+
+ # A child value that opened a flow collection or a quoted scalar has not
+ # ended: flow and quoted content ignore indentation, so every line until it
+ # closes is nested content of that value and never a direct child of the
+ # authentication mapping. Reading one as a child is what answered `named`
+ # for a class that only ever reaches `authentication.config`.
+ if (SP > 0 || SQ != "") {
+ flow_span(line)
+ return
+ }
+
+ # `authenticator:` can carry no value on its own line and leave the scalar
+ # to the following, deeper one, which YAML folds into that value and
+ # Settings.read() hands over as the class. Comment and blank lines are not
+ # content, so this state survives them; the first line that is not deeper
+ # ends it and the key stays the valueless one it looked like.
+ if (PENDING) {
+ PENDING = 0
+ if (ind > PENDING_IND) {
+ t = trim(line)
+ f = substr(t, 1, 1)
+ # A nested mapping or collection in that position is not a class
+ # name. Calling it one would arm REST beside a server that either
+ # dies on the shape or finds no authenticator, so refuse it.
+ if (split_pair(t) || f == "{" || f == "[" || f == "-" || f == "?")
+ NESTED_VAL = 1
+ else
+ AUTH_NAMED = names_class(t)
+ return
+ }
+ }
+
+ # An explicit key at the document root is a mapping this reader does not
+ # walk, and Settings.read() still builds `? authentication` together with
+ # its `: ...` value line into the authentication mapping. The key search
+ # below meets no `key: value` pair on those lines and would answer `none`,
+ # which starts REST open beside a Gremlin that authenticates, so refuse.
+ t = trim(line)
+ if ((ROOT_IND < 0 || ind == ROOT_IND) &&
+ (t == "?" || substr(t, 1, 2) == "? ")) {
+ EXPLICIT = 1
+ return
+ }
+
+ # A root key preceded by a tag, an anchor or an alias is the authentication
+ # key to SnakeYAML -- `!!str authentication` and `&k authentication` both
+ # resolve to it and the server builds the mapping -- but resolving node
+ # properties is outside this reader and the sibling line below it would set
+ # the root indentation, so the mapping went unnoticed and the answer came
+ # out `none`. That is the direction which starts REST open beside a Gremlin
+ # that authenticates, so it is refused the way an explicit key is. A
+ # property in front of a different key is an ordinary root sibling, so it
+ # falls through and keeps setting the indentation and closing mappings the
+ # way an untagged one does.
+ if ((ROOT_IND < 0 || ind == ROOT_IND) && split_pair(line) &&
+ substr(unquote(K_TXT), 1, 1) ~ /^[*&!]/ &&
+ strip_node_props(unquote(K_TXT)) == "authentication") {
+ ROOT_PROP = 1
+ return
+ }
+
+ # The indentation of the first real content line is the root indentation.
+ # A document marker or a stray scalar opens no mapping, so keep looking
+ # until a key:value line is met. Every comparison below is against that
+ # indentation rather than column 0, so a root mapping written indented --
+ # valid to Settings.read() -- is recognized, while an `authentication:`
+ # nested under some other key is still not mistaken for the Gremlin one.
+ if (ROOT_IND < 0) {
+ # A document written as one flow mapping is a shape this reader does not
+ # walk, and its authenticator sits behind a root key rather than at the
+ # root indentation. Answering `none` for it is what left REST open
+ # beside a Gremlin that authenticates, so it is refused.
+ if (substr(trim(line), 1, 1) == "{") {
+ ROOT_FLOW = 1
+ return
+ }
+ if (!split_pair(line)) return
+ ROOT_IND = ind
+ } else if (ind == ROOT_IND && in_auth && !flow) {
+ # A root-level sibling closes the mapping being read -- but not while a
+ # flow collection is still open, or the closing brace of a flow mapping
+ # spread over several lines was taken for a sibling and the direct
+ # authenticator it did name was never committed.
+ in_auth = 0
+ flow = 0
+ child = -1
+ }
+
+ # A top-level authentication key opens a mapping. Count them and read to
+ # EOF rather than exiting at the first, and clear the child state when a
+ # later one opens: two top-level mappings resolve to the LAST one, so the
+ # answer has to describe that node and not the first the scanner met.
+ if (ind == ROOT_IND && split_pair(line) &&
+ unquote(K_TXT) == "authentication") {
+ AUTH_BLOCKS++
+ if (AUTH_BLOCKS > 1) {
+ AUTH_SEEN = 0
+ AUTH_NAMED = 0
+ # A shape the earlier mapping left unresolved says nothing about the
+ # mapping the server actually loads, so it goes with the reset.
+ NESTED_VAL = 0
+ }
+ in_auth = 1
+ child = -1
+ v = unanchor(V_TXT)
+ if (substr(v, 1, 1) == "{") {
+ # A flow mapping is the value whether or not an anchor sits in
+ # front of the brace, and it may stay open past this line.
+ flow = 1
+ if (scan_flow(v)) {
+ in_auth = 0
+ flow = 0
+ }
+ }
+ # Anything else on the key line -- a scalar, a sequence, nothing -- is
+ # not a mapping that names a class. Reading `authentication: some.Name`
+ # as named would accept a config the server cannot use.
+ return
+ }
+
+ if (!in_auth) return
+
+ if (flow) {
+ if (scan_flow(line)) {
+ in_auth = 0
+ flow = 0
+ }
+ return
+ }
+
+ # Inside a block mapping: the first child sets the child indentation, and
+ # only a direct child at that indentation counts. A line reaching here is
+ # never at the root indentation (the sibling case above consumed those),
+ # so `child` is always deeper than the root, as a real child must be.
+ if (child < 0) {
+ # The mapping may be a flow collection that opens on the first child
+ # line instead of on the key line. Its braces hold the direct entries,
+ # so the flow reader has to be the one that sees them.
+ v = unanchor(line)
+ if (substr(v, 1, 1) == "{") {
+ child = ind
+ flow = 1
+ if (scan_flow(v)) {
+ in_auth = 0
+ flow = 0
+ }
+ return
+ }
+ }
+ if (!split_pair(line)) {
+ opens_child_span(line)
+ return
+ }
+ if (child < 0) child = ind
+ # Every child is checked for a collection it leaves open, at any
+ # indentation, so that a nested one swallows its own continuation lines
+ # before they can be counted as a direct child.
+ opens_child_span(V_TXT)
+ if (SP > 0 || SQ != "") return
+ if (ind != child) return
+ if (names_authenticator(K_TXT)) {
+ AUTH_SEEN++
+ if (is_block_scalar(V_TXT)) {
+ # The class, if this names one at all, is on the deeper lines that
+ # follow rather than on the key line.
+ BLOCK = 1
+ BLOCK_IND = ind
+ BLOCK_TXT = ""
+ return
+ }
+ if (trim(V_TXT) == "") {
+ # `authenticator:` with nothing behind it: the value may still be
+ # the next deeper line rather than the empty node this line shows.
+ PENDING = 1
+ PENDING_IND = ind
+ return
+ }
+ AUTH_NAMED = names_class(V_TXT)
+ }
+}
+
+{
+ # YAML ends a line at CR, LF or CRLF, but awk splits records on LF alone, so
+ # a file written with bare CR terminators arrives as one long record whose
+ # root `authentication:` key is never met. Splitting each record on CR
+ # gives every spelling its own line; the CR that a Linux reader leaves at
+ # the end of a CRLF record simply yields the empty segment that the blank
+ # check drops.
+ # A byte order mark belongs to the stream, not to the first key, so it is
+ # dropped before the record is split; see rule 11 above.
+ rec = $0
+ if (NR == 1) rec = strip_bom(rec)
+ seg_n = split(rec, seg, /\r/)
+ for (seg_i = 1; seg_i <= seg_n; seg_i++) handle_line(seg[seg_i])
+}
+
+END {
+ if (BLOCK) finish_block()
+ if (ROOT_FLOW) RESULT = refuse("a document written as a root flow mapping")
+ else if (UNRESOLVED)
+ RESULT = refuse("a quoted key or value carrying an escape that is not resolvable here")
+ # A collection or quote that never closed is a file the server rejects, so
+ # no answer here can be right; stopping the boot is the safe one.
+ else if (SP > 0 || SQ != "")
+ RESULT = refuse("a flow collection or quoted scalar left open in the authentication mapping")
+ else if (EXPLICIT)
+ RESULT = refuse("an explicit key, question mark then space, opening a root mapping")
+ else if (ROOT_PROP)
+ RESULT = refuse("the authentication key preceded by a tag, an anchor or an alias")
+ else if (NESTED_VAL)
+ RESULT = refuse("an authenticator whose value is a nested mapping or collection")
+ else if (AUTH_BLOCKS == 0) RESULT = "none"
+ # The last top-level mapping is the node Settings.read() loads, whether or
+ # not there were others above it, so one answer covers both files.
+ else RESULT = auth_state()
+ print RESULT
+}
diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh
index fcdadd906f..f13a453e4c 100644
--- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh
+++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/enable-auth.sh
@@ -16,6 +16,8 @@
# limitations under the License.
#
+set -euo pipefail
+
function abs_path() {
SOURCE="${BASH_SOURCE[0]}"
while [[ -h "$SOURCE" ]]; do
@@ -34,23 +36,295 @@ GREMLIN_SERVER_CONF="gremlin-server.yaml"
REST_SERVER_CONF="rest-server.properties"
GRAPH_CONF="hugegraph.properties"
+fail() {
+ echo "enable-auth.sh: $*" >&2
+ exit 1
+}
+
+# Reading and writing .properties files goes through props.awk, the same helper
+# the docker entrypoint uses, because the keys below can be spelled in every way
+# java.util.Properties accepts: `=`/`:`/bare-whitespace separators, a form feed
+# as whitespace, `\.` or `\u002e` for the dots, and LF, CRLF or CR line
+# terminators. grep and sed see a different file. A legal
+# `gremlin\u002egraph=org.apache.hugegraph.HugeFactory` matched no pattern at
+# all, so the factory was never wrapped for auth even though both servers were
+# told authentication was on -- and the CR byte that the previous pattern had
+# to be handed a carriage return for is now handled by the reader itself.
+#
+# props.awk is packaged in this same bin/ directory by the release assembly, so
+# it is present in the tarball and in the image. PROPS_AWK lets a caller point
+# at a different copy; the entrypoint reads that same variable for itself.
+for candidate in "${PROPS_AWK:-}" "${BIN}/props.awk" "${TOP}/props.awk"; do
+ if [[ -n "${candidate}" && -f "${candidate}" ]]; then
+ PROPS_AWK="${candidate}"
+ break
+ fi
+done
+[[ -n "${PROPS_AWK:-}" ]] || fail "props.awk not found beside this script"
+
+# The Gremlin half of the decision below has to be made by the same reader the
+# entrypoint uses. yamlscan.awk is not in bin/: the Dockerfile places it in the
+# install home, one above this script, which is where the image layout is
+# mirrored in the test tree; PROPS_AWK and YAMLSCAN_AWK cover a caller that
+# keeps it elsewhere. The release tarball carries no copy at all, so the
+# fallback below has to answer on its own.
+YAMLSCAN=""
+for candidate in "${YAMLSCAN_AWK:-}" "${TOP}/yamlscan.awk" "${BIN}/yamlscan.awk"; do
+ if [[ -n "${candidate}" && -f "${candidate}" ]]; then
+ YAMLSCAN="${candidate}"
+ break
+ fi
+done
+
+# props_get is the only reader used here, and it treats any nonzero status from
+# props.awk as an error: 2 means the file could not be read at all, which must
+# not be mistaken for "the key is not there" and answered with a write.
+props_get() {
+ local status=0 value
+ value=$(PROPS_MODE=get PROPS_DECODED=1 PROPS_KEY="$1" PROPS_FILE="$2" \
+ awk -f "${PROPS_AWK}" /dev/null) || status=$?
+ if (( status > 0 )); then
+ fail "cannot read $2"
+ fi
+ printf '%s' "${value}"
+}
+
+# The character check for every class this script writes. Java class names need
+# no properties escaping; anything else would have to go through the entrypoint's
+# encoder first. It is one function rather than a pattern at each call site so
+# the early refusal below and the write cannot drift apart.
+check_class_name() {
+ case "$1" in
+ *[!A-Za-z0-9_\.\$]*) fail "refusing to write an unescaped value: $1" ;;
+ esac
+}
+
+props_set() {
+ check_class_name "$2"
+ PROPS_MODE=set PROPS_KEY="$1" PROPS_VALUE_ENCODED="$2" PROPS_FILE="$3" \
+ awk -f "${PROPS_AWK}" /dev/null || fail "cannot update $3"
+}
+
+# Give `$3` its default `$2` for key `$1`, in place, unless it already has a
+# value. Guarding on the key being present and appending was not the same
+# question:
+# `auth.authenticator=` and a bare `auth.authenticator` line both parse to the
+# empty string (measured against java.util.Properties, which also strips the
+# trailing blanks of `auth.authenticator= `), so a presence check reported
+# them as
+# answered and the append was skipped -- while the entrypoint's
+# check_auth_sides, which asks for the value rather than the key, counted the
+# same file as unconfigured. `loadAuthenticator("")` returns null, so REST then
+# served without authentication next to a Gremlin that required it.
+#
+# props_set covers both shapes the guard had to split: with a definition
+# present it replaces the first one where it stands (no duplicate for
+# first-definition-wins to bury), with none present it appends.
+#
+# `$4` is the key's current value, read before the first write. Reading it
+# here instead -- `[[ -n "$(props_get "$1" "$3")" ]]` -- discarded the status of
+# the command substitution, because `[[` only looks at the text, so props_get's
+# refusal of a file props.awk cannot read (an `include` directive) exited the
+# subshell, the guard saw an empty string, and props_set then tried to write
+# that same unreadable file.
+ensure_rest_prop() {
+ [[ -n "$4" ]] && return 0
+ props_set "$1" "$2" "$3"
+}
+
# make a backup
BAK_CONF="$TOP/conf-bak"
if [ ! -d "$BAK_CONF" ]; then
- mkdir -p "$BAK_CONF"
- cp "${CONF}/${GREMLIN_SERVER_CONF}" "${BAK_CONF}/${GREMLIN_SERVER_CONF}.bak"
- cp "${CONF}/${REST_SERVER_CONF}" "${BAK_CONF}/${REST_SERVER_CONF}.bak"
- cp "${CONF}/graphs/${GRAPH_CONF}" "${BAK_CONF}/${GRAPH_CONF}.bak"
+ mkdir -p "$BAK_CONF" || fail "cannot create ${BAK_CONF}"
+ cp "${CONF}/${GREMLIN_SERVER_CONF}" "${BAK_CONF}/${GREMLIN_SERVER_CONF}.bak" ||
+ fail "cannot back up ${GREMLIN_SERVER_CONF}"
+ cp "${CONF}/${REST_SERVER_CONF}" "${BAK_CONF}/${REST_SERVER_CONF}.bak" ||
+ fail "cannot back up ${REST_SERVER_CONF}"
+ cp "${CONF}/graphs/${GRAPH_CONF}" "${BAK_CONF}/${GRAPH_CONF}.bak" ||
+ fail "cannot back up ${GRAPH_CONF}"
+fi
+
+# Both writes below skip a side that already carries a real value, so they are
+# no-ops on a mounted config or a re-run. Appending unconditionally used to
+# create duplicate definitions that the properties parser (first definition
+# wins) and the yaml parser (last wins) resolved in opposite directions, leaving
+# Gremlin and REST on different authenticators. That is why the REST side goes
+# through ensure_rest_prop rather than a presence guard plus an append: a
+# presence guard also lets a defined-but-empty key count as answered, and the
+# appended default would then be the definition the server never reads.
+#
+# Appended with `>>` rather than `sed -i '$a\...'`: GNU sed's `$` address never
+# matches when the file has no lines, so on an empty mounted config every append
+# silently did nothing. `sed -i '$a'` also closed the previous last line for us,
+# which `>>` does not, so a file without a trailing newline gets one first.
+#
+# Every write here has to be seen to succeed. The docker entrypoint runs this
+# script and trusts its exit status, and a partially updated tree -- REST
+# configured, yaml append refused by a read-only mounted file -- is exactly the
+# one-sided state the entrypoint refuses to start with. Without errexit and
+# these checks the script exited 0 on that half-done job.
+append_lines() {
+ local file="$1"
+ shift
+ if [[ ! -w "${file}" ]]; then
+ fail "cannot append to ${file}: not writable"
+ fi
+ if [[ -s "${file}" && -n "$(tail -c 1 "${file}")" ]]; then
+ printf '\n' >> "${file}" || fail "cannot append to ${file}"
+ fi
+ printf '%s\n' "$@" >> "${file}" || fail "cannot append to ${file}"
+}
+
+AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}"
+
+# Refused here, before the first write, rather than at props_set's check when the
+# value reaches rest-server.properties. That check ran one statement after the
+# yaml block below carried the same class into gremlin-server.yaml, so a value
+# with a space in it left the yaml naming a class no server can load beside an
+# untouched REST config -- the one-sided tree the entrypoint's check_auth_sides
+# then stops the next boot on. It did not self-repair either: once the yaml
+# reads as `named` the append is skipped, so re-running with the variable fixed
+# or unset wrote the default to REST only, exited 0, and left the two servers
+# authenticating to different classes.
+check_class_name "${AUTHENTICATOR_CLASS}"
+
+# Does the Gremlin config carry a top-level `authentication` mapping, and does
+# that mapping name an authenticator? This is the same question
+# check_auth_sides answers, so it has to go to the same reader: a mapping is
+# the server's only at column 0, comment text is not content, and the key may
+# be quoted. grep asks it differently -- it sees only the bare spelling, so an
+# operator's `"authentication":` block read as absent and a second default
+# block was appended beside it, after which the two servers can resolve the key
+# in opposite directions while REST keeps its existing authenticator.
+#
+# The answer lands in GREMLIN_AUTH, and there are three of them because two
+# were not enough: whether a mapping exists says nothing about whether it
+# names a class, and only the second one decides what is safe to write.
+# none no mapping, so the default block below is ours to append;
+# named the operator's mapping names a class;
+# nameless a mapping that names none, or a document the reader refuses
+# rather than guess about -- yamlscan.awk reports both as
+# nameless and check_auth_sides stops the boot on them.
+# unverifiable only the tarball fallback can produce this: grep saw the key
+# but has no reader to tell the three cases apart.
+GREMLIN_AUTH=""
+gremlin_auth_state() {
+ local file="$1"
+ GREMLIN_AUTH="none"
+ [[ -f "${file}" ]] || return 0
+ if [[ -n "${YAMLSCAN}" ]]; then
+ GREMLIN_AUTH=$(awk -f "${YAMLSCAN}" "${file}") || fail "cannot read ${file}"
+ return 0
+ fi
+ # No parser in this layout (the plain release tarball). Match what grep can
+ # honestly answer here: a column-0 key in either quote style or none. The
+ # nested-mapping and comment cases are the ones that need the real reader,
+ # and the image, where the entrypoint runs this script, always has it.
+ if grep -Eq "^[\"']?authentication[\"']?[[:blank:]]*:" "${file}"; then
+ GREMLIN_AUTH="unverifiable"
+ fi
+}
+
+# Writing `auth.authenticator` is the one-way door: REST starts enforcing on
+# the next boot, and TinkerPop 3.5.1 resolves a mapping that names no
+# authenticator to AllowAllAuthenticator, so Gremlin keeps answering without
+# credentials. That is the same one-sided state this script exists to avoid,
+# arrived at by a route the entrypoint does not guard -- enable-auth.sh ships
+# in the release tarball, where nothing calls check_auth_sides first, so the
+# refusal has to live here rather than lean on the caller.
+gremlin_auth_state "${CONF}/${GREMLIN_SERVER_CONF}"
+
+if [[ "${GREMLIN_AUTH}" == "nameless" ]]; then
+ fail "${GREMLIN_SERVER_CONF} carries an authentication mapping that names no authenticator, or a shape the reader refuses; writing ${REST_SERVER_CONF} beside it would enforce on REST and leave Gremlin on its default. Name authentication.authenticator in that mapping, or drop the mapping and let this script write both sides."
+fi
+# Every value the decisions below act on is read here, before the first write.
+# A plain assignment is what makes the read fatal: errexit sees the status of a
+# command substitution on an assignment statement, while `[[ -n "$(props_get
+# ...)" ]]` looked only at the text and discarded it. Reading rest-server.properties
+# from ensure_rest_prop instead discovered an unreadable file one statement after
+# the yaml block had been appended, leaving the yaml naming StandardAuthenticator
+# beside a REST config with no `auth.authenticator` -- the one-sided tree this
+# script exists to prevent, which in the tarball layout then refuses to repair
+# itself: the appended block reads as a mapping, so a rerun after the operator
+# fixes the include line stops on the unverifiable branch below.
+# graphs/hugegraph.properties was read last of all, so an unreadable graph config
+# let the run write both auth sides and leave the factory unwrapped.
+REST_AUTHENTICATOR=$(props_get "auth.authenticator" "${CONF}/${REST_SERVER_CONF}")
+REST_GRAPH_STORE=$(props_get "auth.graph_store" "${CONF}/${REST_SERVER_CONF}")
+GRAPH_FACTORY=$(props_get "gremlin.graph" "${CONF}/graphs/${GRAPH_CONF}")
- sed -i -e '$a\authentication: {' \
- -e '$a\ authenticator: org.apache.hugegraph.auth.StandardAuthenticator,' \
- -e '$a\ authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \
- -e '$a\ config: {tokens: conf/rest-server.properties}' \
- -e '$a\}' ${CONF}/${GREMLIN_SERVER_CONF}
+# Wrap the graph factory only when it really is the plain HugeFactory, which is
+# a question about the decoded value, so it goes through the same reader.
+#
+# The trailing blanks come off before the comparison because the server reads
+# the trimmed line: commons-configuration right-trims a property line before it
+# resolves the class, so a mounted `gremlin.graph=org.apache.hugegraph.HugeFactory `
+# opens the graph through the plain factory exactly as if it carried no blanks.
+# java.util.Properties by itself keeps them (measured against JDK 17), which is
+# why the reader hands the value back verbatim. Comparing the untrimmed bytes
+# left such a config unwrapped: authentication on both servers, and no
+# HugeFactoryAuthProxy in front of the graph, which GraphManager only warns
+# about. A factory that is not HugeFactory stays untouched either way.
+#
+# The trim happens here rather than next to the write below because the
+# writability guard under it asks the same question, and two copies of it would
+# be free to disagree.
+while [[ "${GRAPH_FACTORY}" =~ [[:space:]]$ ]]; do
+ GRAPH_FACTORY="${GRAPH_FACTORY%?}"
+done
+
+# A config the script can read but not write has to be refused here, before the
+# yaml block below, for the same reason the reads moved up. `append_lines`
+# guards only the file it appends to, so with a read-only rest-server.properties
+# the yaml gained the authentication mapping, props_set then failed on the REST
+# side, and the run exited 1 leaving the one-sided tree the comment above
+# `append_lines` says must not happen -- and in the release tarball it does not
+# repair itself: the appended block reads as a mapping, so the rerun after the
+# operator restores write access stops on the unverifiable branch and the yaml
+# has to be edited by hand. Only a file this run actually owes a write to is
+# guarded, so a read-only config that already answers every key still passes.
+# `-w` asks the shell rather than reading the mode because root can write a 0444
+# file, and refusing that would break the image it is meant to protect.
+if [[ -z "${REST_AUTHENTICATOR}" || -z "${REST_GRAPH_STORE}" ]] &&
+ ! [[ -w "${CONF}/${REST_SERVER_CONF}" ]]; then
+ fail "${REST_SERVER_CONF} is not writable and does not answer auth.authenticator and auth.graph_store, so this run owes it a write it cannot make; nothing was written"
+fi
+if [[ "${GRAPH_FACTORY}" == "org.apache.hugegraph.HugeFactory" ]] &&
+ ! [[ -w "${CONF}/graphs/${GRAPH_CONF}" ]]; then
+ fail "graphs/${GRAPH_CONF} is not writable and gremlin.graph is the plain HugeFactory, so this run owes it a write it cannot make; nothing was written"
+fi
+
+if [[ "${GREMLIN_AUTH}" == "unverifiable" ]] && [[ -z "${REST_AUTHENTICATOR}" ]]; then
+ # The operator already naming a class on the REST side is the one answer
+ # this layout can act on without a reader: ensure_rest_prop then has
+ # nothing to write, so both sides stay as the operator left them.
+ fail "${GREMLIN_SERVER_CONF} has a top-level authentication mapping and this layout has no yaml reader to tell whether it names an authenticator, while ${REST_SERVER_CONF} names none. Set auth.authenticator there yourself, or run this from the server image, which ships the reader."
+fi
+
+# Only a column-0 `authentication` mapping is the Gremlin server's, which is the
+# rule yamlscan.awk applies to decide the same thing for check_auth_sides. With
+# a guard that disagreed on nesting, the entrypoint read the file as `none`, so
+# parity held and it called this script, but the guard saw the nested key and
+# skipped the append, writing the REST side only -- StandardAuthenticator on
+# REST, TinkerPop's AllowAllAuthenticator on Gremlin.
+if [[ "${GREMLIN_AUTH}" == "none" ]]; then
+ append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \
+ 'authentication: {' \
+ " authenticator: ${AUTHENTICATOR_CLASS}," \
+ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \
+ ' config: {tokens: conf/rest-server.properties}' \
+ '}'
+fi
- sed -i -e '$a\auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \
- -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF}
+ensure_rest_prop "auth.authenticator" "${AUTHENTICATOR_CLASS}" \
+ "${CONF}/${REST_SERVER_CONF}" "${REST_AUTHENTICATOR}"
+ensure_rest_prop "auth.graph_store" "hugegraph" \
+ "${CONF}/${REST_SERVER_CONF}" "${REST_GRAPH_STORE}"
- sed -i 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' ${CONF}/graphs/${GRAPH_CONF}
+# The value was trimmed, and the writability of this file was refused before the
+# yaml append, both next to the reads that decided them.
+if [[ "${GRAPH_FACTORY}" == "org.apache.hugegraph.HugeFactory" ]]; then
+ props_set "gremlin.graph" "org.apache.hugegraph.auth.HugeFactoryAuthProxy" \
+ "${CONF}/graphs/${GRAPH_CONF}"
fi
diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk
new file mode 100644
index 0000000000..22049aa8d0
--- /dev/null
+++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk
@@ -0,0 +1,437 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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
+#
+# http://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.
+#
+# props.awk — read and rewrite Java ".properties" files with the grammar
+# HugeConfig (commons-configuration over JDK Properties) applies, so the
+# entrypoint and the server agree on what a mounted file means. grep/sed
+# rewrites do not: they see `\`-escaped keys, `:` separators, continuation
+# lines and duplicate definitions differently, which is how a mounted
+# config ends up with two definitions of one key.
+#
+# One invocation, selected with the `PROPS_MODE` environment variable:
+#
+# PROPS_MODE=get PROPS_KEY=K PROPS_FILE=F
+# print the value of K's first logical definition, in the on-disk
+# escaped form; with PROPS_DECODED=1 print it as java.util.Properties
+# would hand it to the server. Exits 0 when K has no definition, which
+# prints nothing, and 2 when the file cannot be read or uses an include
+# directive. The guards that append a default -- `check_auth_sides` and
+# `props_get` -- rely on that nonzero status to refuse rather than treat
+# an unreadable file as an absent key.
+# PROPS_MODE=set PROPS_KEY=K PROPS_FILE=F
+# replace K's first definition in place, drop every other
+# definition of K, append one when the file has none. The new
+# value arrives pre-encoded in PROPS_VALUE_ENCODED (an environment
+# variable, so secrets never appear in `ps` output or in awk's
+# argv), and -v is not used for it so awk cannot mangle its
+# backslash escapes. A file that cannot be written exits 2 as
+# well, before anything is staged, so a read-only config neither
+# loses a secret into a leftover temp file nor reads as damaged.
+#
+# Grammar implemented (java.util.Properties line reader + the
+# first-definition-wins rule Configuration.getString applies):
+# - physical lines end at \r\n, \n or a bare \r, as in java.util.Properties
+# - '#' / '!' comments and blank lines
+# - '=' / ':' / whitespace separators, where the whitespace Java counts is
+# space, tab and form feed, with whitespace then an optional single '=' or
+# ':' accepted as one separator
+# - continuations: a physical line ending in an odd number of
+# backslashes joins the next line (its leading whitespace stripped)
+# - backslash escapes in keys and values, including \uXXXX
+# - duplicate logical keys resolve to the first definition
+#
+# Rewrites keep every untouched line byte-for-byte (comments, blank
+# lines, unrelated entries), and replace the first definition where it
+# stands, so mounted configs stay reviewable in git diffs.
+#
+# A file that carries commons-configuration's `include` directive is refused in
+# every mode, with exit status 2: the directive splices another file into this
+# one, so "this key is absent" is a question that cannot be answered from this
+# file alone, and writing into it could bury the definition the server reads.
+# Rewrites stage through two exclusively created 0600 files (`mktemp`) rather
+# than a predictable `.tmp` / `.bak`, which a writer in a mounted
+# conf directory could have arranged as a symlink before the entrypoint, running
+# as root, opened either of them.
+
+function die(msg) {
+ printf "props.awk: %s\n", msg > "/dev/stderr"
+ # 2 for an error. An absent key answers with empty output at exit 0, so an
+ # error needs a status of its own: a caller that read any nonzero as "not
+ # there" would append a definition on top of one it failed to read.
+ exit 2
+}
+
+function hex_digit(c) {
+ return index("0123456789abcdef", tolower(c)) - 1
+}
+
+# \uXXXX is a UTF-16 code unit in Java. Values here are effectively
+# ISO-8859-1, so codes above 0xFF are kept as their literal escape text
+# rather than being mangled through a single-byte sprintf.
+function unescape(s, out, i, n, c, code, j, d, ok) {
+ out = ""
+ n = length(s)
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (c != "\\") { out = out c; continue }
+ if (i == n) break
+ i++
+ c = substr(s, i, 1)
+ if (c == "u" && i + 4 <= n) {
+ code = 0
+ ok = 1
+ for (j = 1; j <= 4; j++) {
+ d = hex_digit(substr(s, i + j, 1))
+ if (d < 0) { ok = 0; break }
+ code = code * 16 + d
+ }
+ if (ok) {
+ i += 4
+ if (code <= 255) out = out sprintf("%c", code)
+ else out = out substr(s, i - 5, 6)
+ continue
+ }
+ }
+ if (c == "t") out = out "\t"
+ else if (c == "n") out = out "\n"
+ else if (c == "r") out = out "\r"
+ else if (c == "f") out = out "\f"
+ else out = out c
+ }
+ return out
+}
+
+# A physical line is continued when it ends in an odd number of
+# backslashes (an even count escapes itself).
+function trailing_backslashes(s, n, k) {
+ n = length(s)
+ k = 0
+ while (k < n && substr(s, n - k, 1) == "\\") k++
+ return k
+}
+
+function is_skipped(raw) {
+ return raw ~ /^[ \t\f]*([#!]|$)/
+}
+
+# Split a logical line into its raw (still-escaped) key and value parts.
+# Results land in K_RAW / V_RAW because awk returns one value.
+# Java treats form feed as whitespace on both sides of the separator, so
+# `auth.authenticator=...` is one property here too; reading it as part of
+# the key name made a valid mounted configuration invisible to the guards.
+function split_kv(s, n, i, c, esc, sep_at, rest) {
+ n = length(s)
+ esc = 0
+ sep_at = 0
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (esc) { esc = 0; continue }
+ if (c == "\\") { esc = 1; continue }
+ if (c == "=" || c == ":" || c == " " || c == "\t" || c == "\f") { sep_at = i; break }
+ }
+ if (sep_at == 0) {
+ K_RAW = s
+ V_RAW = ""
+ return
+ }
+ K_RAW = substr(s, 1, sep_at - 1)
+ rest = substr(s, sep_at)
+ c = substr(rest, 1, 1)
+ if (c == "=" || c == ":") {
+ rest = substr(rest, 2)
+ } else {
+ sub(/^[ \t\f]+/, "", rest)
+ c = substr(rest, 1, 1)
+ if (c == "=" || c == ":") rest = substr(rest, 2)
+ }
+ sub(/^[ \t\f]+/, "", rest)
+ V_RAW = rest
+}
+
+function shquote(s) {
+ gsub(/'/, "'\\''", s)
+ return "'" s "'"
+}
+
+# A private temporary file, created exclusively, beside `file`.
+#
+# The name has to come from mktemp. With a fixed `.tmp` anyone able to
+# write in a mounted conf directory could leave that name as a symlink to a
+# file elsewhere in the container, and neither the shell redirection that
+# pre-created it nor awk's own `>` checks for that: both follow it, so the
+# entrypoint, running as root by default, would write auth.admin_pa or
+# auth.token_secret through the link and into whatever it points at. An
+# exclusive create of an unpredictable name cannot be pre-arranged, and mktemp
+# makes the file 0600 whatever the umask says, which is the reason no chmod
+# follows it here.
+#
+# The template is quoted, which is also why no `--` is passed: the argument
+# starts at a quote byte, so it can never read as an option.
+function make_temp(file, kind, cmd, path) {
+ path = ""
+ cmd = "umask 077 && mktemp " shquote(file) "." kind ".XXXXXX"
+ if ((cmd | getline path) <= 0 || path == "") {
+ close(cmd)
+ die("cannot create a private " kind " file beside " file)
+ }
+ close(cmd)
+ return path
+}
+
+# java.util.Properties ends a physical line at \r\n, \n or a bare \r, but
+# getline splits on \n alone. A properties file saved with CR-only endings --
+# which java.util.Properties writes for a lone `store()` on some platforms, and
+# which a mounted config can arrive with -- therefore reached the parser as one
+# enormous record: only its first key was ever seen, and rewriting that key
+# replaced the whole record and dropped every later entry, including
+# auth.authenticator. So the file is re-scanned for terminators here.
+#
+# RAW[] keeps the exact bytes of each line and RAWTERM[] its terminator, so a
+# rewrite still replays untouched lines byte-for-byte. A file whose last line
+# carries no terminator gets a \n, which is what the replay did before.
+function scan_records(s, i, n, c, start, term, len, cnt) {
+ n = length(s)
+ cnt = 0
+ start = 1
+ i = 1
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c != "\r" && c != "\n") { i++; continue }
+ if (c == "\r" && substr(s, i + 1, 1) == "\n") {
+ term = "\r\n"
+ len = 2
+ } else {
+ term = c
+ len = 1
+ }
+ cnt++
+ RAW[cnt] = substr(s, start, i - start)
+ RAWTERM[cnt] = term
+ start = i + len
+ i = start
+ }
+ if (start <= n) {
+ cnt++
+ RAW[cnt] = substr(s, start)
+ RAWTERM[cnt] = ""
+ }
+ return cnt
+}
+
+# Load `file` into per-block arrays: one block per comment/blank line or
+# logical entry, spanning exactly the physical lines it occupies.
+function props_load(file, raw, rc, content, nl, stripped, next_raw, start, logical, inc) {
+ content = ""
+ while ((rc = (getline raw < file)) > 0)
+ content = content raw "\n"
+ if (rc == -1)
+ die("cannot read " file)
+ close(file)
+
+ NLINES = scan_records(content)
+
+ NBLOCK = 0
+ for (nl = 1; nl <= NLINES; nl++) {
+ # RAW[] holds one java.util.Properties physical line with its terminator
+ # already removed, so no CR stripping is needed here.
+ stripped = RAW[nl]
+ if (is_skipped(stripped)) {
+ NBLOCK++
+ BTYPE[NBLOCK] = "skip"
+ BFIRST[NBLOCK] = nl
+ BLAST[NBLOCK] = nl
+ continue
+ }
+ start = nl
+ logical = stripped
+ while (trailing_backslashes(logical) % 2 == 1 && nl < NLINES) {
+ logical = substr(logical, 1, length(logical) - 1)
+ nl++
+ next_raw = RAW[nl]
+ sub(/^[ \t\f]+/, "", next_raw)
+ logical = logical next_raw
+ }
+ # java.util.Properties ignores whitespace before the key; strip it
+ # so split_kv's separator scan agrees (an indented key used to be
+ # read as a key whose name started with a space, and a set then
+ # appended a second definition of the real key).
+ sub(/^[ \t\f]+/, "", logical)
+ split_kv(logical)
+ NBLOCK++
+ BTYPE[NBLOCK] = "entry"
+ BFIRST[NBLOCK] = start
+ BLAST[NBLOCK] = nl
+ BKEY[NBLOCK] = unescape(K_RAW)
+ # An include directive is not an ordinary property to the server: commons
+ # configuration splices the named file into this one at this point, so
+ # auth.authenticator can be defined over there and be invisible from
+ # here, and which of the two definitions wins follows the spliced
+ # order rather than the order of this file. Answering that needs the
+ # parser the server uses, and answering it wrong is how a mounted
+ # config boots with REST open and Gremlin protected. Commons
+ # configuration 2 treats both `include` and `includeOptional` as
+ # directives, and matches the property name case-insensitively, so the
+ # guard below rejects every spelling a real loader would honour -- not
+ # just the exact lowercase `include` this first refused. A file that
+ # uses any of them is refused in every mode and nothing is written.
+ inc = tolower(BKEY[NBLOCK])
+ if (inc == "include" || inc == "includeoptional")
+ die("refusing to read or rewrite " file ": it uses an include directive (line " start "), which this helper cannot resolve")
+ # Values stay in their on-disk escaped form. get Prop callers feed
+ # the result straight back into set, which would corrupt a decoded
+ # value by re-writing its backslashes as literals; keys are
+ # unescaped because they are matched against plain names.
+ BVAL[NBLOCK] = V_RAW
+ }
+}
+
+function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs, tail) {
+ props_load(file)
+ # A value whose written form leaves an odd number of backslashes at the end
+ # of the physical line turns the line after it into a continuation of that
+ # value. The line has to be judged as the server sees it: commons
+ # configuration trims the line before it looks for the continuation, so
+ # `abc\ ` -- the spelling encode_prop_value used to give a trailing space --
+ # reaches the server as `abc\` and swallows whatever follows it. Measured
+ # against commons-configuration2 (what HugeConfig extends), the same input
+ # read back yields no property at all, so a secret written this way never
+ # reaches the server that is supposed to authenticate with it. The
+ # entrypoint has to refuse instead of guessing a target.
+ tail = enc_val
+ sub(/[ \t\f\r]+$/, "", tail)
+ nbs = 0
+ while (nbs < length(tail) && substr(tail, length(tail) - nbs, 1) == "\\")
+ nbs++
+ if (nbs % 2 == 1)
+ die("refusing to write " key ": trimmed of its trailing blanks the value ends in a backslash, which would swallow the next line")
+ # Refuse a destination that cannot be written before staging anything, not
+ # after. Without this the copy-back below is the first write attempted, so
+ # a read-only config produced the failure message at the end of this
+ # function -- and that message is only true of a copy that got partway.
+ # The shell opens the destination for writing before cat has written a byte,
+ # so EACCES leaves the original untouched while the staged temp file, which
+ # carries the value being written, stays beside it. A container on a restart
+ # policy therefore adds one temp/snapshot pair per restart, each holding the
+ # secret. `test -w` is the cheap answer and it is not the whole answer: root
+ # can write a 0444 file, and access() reports a bind-mounted read-only file
+ # as writable, so the compare on copy-back failure below still has to hold.
+ if (system("test -w " shquote(file)) != 0)
+ die(file " is not writable; nothing written")
+ first = 0
+ for (b = 1; b <= NBLOCK; b++) {
+ if (BTYPE[b] == "entry" && BKEY[b] == key) {
+ if (first == 0) first = b
+ else BDROP[b] = 1
+ }
+ }
+ # Staged rewrite: everything lands in a private temp file first, so a
+ # failure before the copy-back leaves the original untouched. The temp file
+ # holds secrets, so it is created 0600 and exclusively (see make_temp): a
+ # reused, predictable name is both a disclosure risk under the process umask
+ # and a path someone else can have arranged already.
+ tmp = make_temp(file, "tmp")
+ for (b = 1; b <= NBLOCK; b++) {
+ if (BDROP[b]) continue
+ if (b == first) {
+ printf "%s=%s\n", key, enc_val > tmp
+ } else {
+ for (ln = BFIRST[b]; ln <= BLAST[b]; ln++) {
+ # Replay the line with the terminator it was read with, so a
+ # CRLF or CR-only config keeps its endings on lines the
+ # rewrite does not touch.
+ msg = RAWTERM[ln]
+ if (msg == "") msg = "\n"
+ printf "%s%s", RAW[ln], msg > tmp
+ }
+ }
+ }
+ if (first == 0)
+ printf "%s=%s\n", key, enc_val > tmp
+ close(tmp)
+ # Copy the completed temp file back onto the original instead of
+ # renaming it: a rename replaces the inode, which would lose the
+ # file's permissions (a 0600 config holding secrets would come back
+ # umask-world-readable), turn a symlinked config into a regular file,
+ # and fail with EBUSY on a config bind-mounted as a single file — the
+ # mounted case this path exists for. The copy keeps the inode, mode,
+ # symlink and mount point.
+ #
+ # The copy itself is not atomic and the shell's `>` truncates the
+ # destination before cat writes a byte, so an ENOSPC or I/O error
+ # mid-copy used to leave a truncated config on disk — a truncated
+ # rest-server.properties loses `auth.authenticator` and boots the
+ # server with authentication off. Snapshot the original first, into a
+ # second exclusively created 0600 file for the same reason as the temp,
+ # and put it back when the copy fails.
+ bak = make_temp(file, "bak")
+ cmd = "cp -- " shquote(file) " " shquote(bak)
+ if (system(cmd) != 0)
+ die("cannot back up " file " before the copy-back")
+ cmd = "cat -- " shquote(tmp) " > " shquote(file)
+ if (system(cmd) != 0) {
+ msg = "cannot copy " tmp " over " file
+ # Only a copy that reached the destination and then failed can damage
+ # it. A failure to open the destination leaves the original
+ # byte-for-byte as it was, so asking the snapshot is what separates the
+ # two: reporting an untouched config as damaged sends the operator to
+ # restore a file that never needed restoring, and leaves the staged
+ # value -- the secret being written -- sitting in the conf directory.
+ if (system("cmp -s -- " shquote(file) " " shquote(bak)) == 0) {
+ if (system("rm -f -- " shquote(tmp) " " shquote(bak)) != 0)
+ die(msg "; " file " is unchanged but " tmp " and " bak " could not be removed")
+ die(msg "; " file " is unchanged and nothing was written")
+ }
+ # Best effort: the destination really is damaged, so restoring it from
+ # the snapshot comes first, and the temp file is kept for an operator
+ # who wants to inspect what was being written.
+ cmd = "cat -- " shquote(bak) " > " shquote(file)
+ if (system(cmd) == 0) die(msg "; the previous content is restored")
+ die(msg "; " file " is damaged, previous content is in " bak)
+ }
+ if (system("rm -f -- " shquote(tmp) " " shquote(bak)) != 0)
+ die("cannot remove " tmp " and " bak " after the copy-back")
+}
+
+function props_get(file, key, decoded, b) {
+ props_load(file)
+ for (b = 1; b <= NBLOCK; b++) {
+ if (BTYPE[b] == "entry" && BKEY[b] == key) {
+ if (decoded) print unescape(BVAL[b])
+ else print BVAL[b]
+ return
+ }
+ }
+ # Absence prints nothing and is NOT an exit status: callers assign from
+ # command substitution (`rest=$(get_prop ...)`) under a shell with errexit
+ # on, where a nonzero status would abort the entrypoint over a merely
+ # missing property. A nonzero status therefore only ever means the file
+ # could not be read or was refused, which is what the guards act on.
+}
+
+BEGIN {
+ mode = ENVIRON["PROPS_MODE"]
+ key = ENVIRON["PROPS_KEY"]
+ file = ENVIRON["PROPS_FILE"]
+ decoded = (ENVIRON["PROPS_DECODED"] == "1")
+ if (file == "" || key == "")
+ die("PROPS_FILE and PROPS_KEY must be set")
+ if (mode == "get") {
+ props_get(file, key, decoded)
+ } else if (mode == "set") {
+ props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"])
+ } else {
+ die("PROPS_MODE must be get or set")
+ }
+}