From 698b0c35f1451a93f18bc2618075ee301b454816 Mon Sep 17 00:00:00 2001 From: Adarsh <122873385+Adarsh-Me@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:17:54 +0530 Subject: [PATCH 01/22] fix(docker): make auth bootstrap safe for mounted and upgraded configs The entrypoint's grep/sed property rewriting disagrees with HugeConfig on mounted or upgraded configs: escaped keys, ':'/whitespace separators, line continuations, and duplicate definitions are all read differently, so a mounted config could end up with two logical definitions of one key. Property reading/writing now goes through props.awk, which implements the java.util.Properties grammar (comments, both separators, continuations, backslash escapes, first-definition-wins duplicates) and keeps every untouched line byte-for-byte. Values travel through environment variables instead of command arguments, so a PASSWORD no longer shows up in 'ps' output when a key is rewritten in place. enable-auth.sh appended authentication definitions whenever conf-bak/ was absent, which on a mounted config created duplicate definitions that the properties parser (first definition wins) and the yaml parser (last definition wins) resolved in opposite directions -- Gremlin and REST could land on different authenticators with no error from either. Its appends are now guarded per file, only an absent or still commented-out definition triggers an append, re-runs are idempotent, and the authenticator class is overridable through AUTHENTICATOR_CLASS. The entrypoint aligns both sides before calling it: it copies a yaml authenticator into rest-server.properties, or exports the REST one for the yaml append, and warns without touching anything when the two name genuinely different authenticators. The unit test suite covers escaped keys, continuations, get-mode semantics, and comment-guarded appends; the entrypoint harness now ships props.awk into its sandbox, and both server Dockerfiles COPY it next to the entrypoint. Fixes #3133 --- hugegraph-server/Dockerfile | 1 + hugegraph-server/Dockerfile-hstore | 1 + .../docker/docker-entrypoint-test.sh | 1 + .../docker/docker-entrypoint.sh | 83 +++++-- .../hugegraph-dist/docker/props.awk | 227 ++++++++++++++++++ .../docker/test/test-docker-entrypoint.sh | 57 ++++- .../src/assembly/static/bin/enable-auth.sh | 26 +- 7 files changed, 375 insertions(+), 21 deletions(-) create mode 100644 hugegraph-server/hugegraph-dist/docker/props.awk diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile index 44bc9aa515..f360adcb68 100644 --- a/hugegraph-server/Dockerfile +++ b/hugegraph-server/Dockerfile @@ -66,6 +66,7 @@ RUN apt-get -q update \ 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 . +COPY hugegraph-server/hugegraph-dist/docker/props.awk . RUN chmod 755 ./docker-entrypoint.sh EXPOSE 8080 diff --git a/hugegraph-server/Dockerfile-hstore b/hugegraph-server/Dockerfile-hstore index fc99034728..81f1063d90 100644 --- a/hugegraph-server/Dockerfile-hstore +++ b/hugegraph-server/Dockerfile-hstore @@ -68,6 +68,7 @@ RUN apt-get -q update \ 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 . +COPY hugegraph-server/hugegraph-dist/docker/props.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 6e22885ebe..6250ab4f14 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -23,6 +23,7 @@ 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" +cp "${SCRIPT_DIR}/props.awk" "${TEST_HOME}/props.awk" touch "${TEST_HOME}/docker/init_complete" cat > "${TEST_HOME}/conf/rest-server.properties" <<'EOF' diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index fe9974c430..ee2994776c 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -26,6 +26,18 @@ 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, 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="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/props.awk" +if [[ ! -f "${PROPS_AWK}" ]]; then + log "ERROR: props.awk not found next to the entrypoint" + exit 1 +fi + encode_prop_value() { local value="$1" encoded="" char local i @@ -48,18 +60,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 +74,58 @@ 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 +} + +# First uncommented `authenticator:` inside the gremlin-server.yaml +# authentication block. snakeyaml resolves duplicate top-level keys to the +# last one, but a mounted file carrying two authentication blocks is +# pathological; report the first and let the mismatch WARN handle it. +get_yaml_authenticator() { + local yaml="./conf/gremlin-server.yaml" + + [[ -f "${yaml}" ]] || return 0 + awk ' + /^[ \t]*#/ { next } + /^[ \t]*authentication[ \t]*:/ { inblk = 1; next } + inblk && /^[ \t]+authenticator[ \t]*:/ { + line = $0 + sub(/^[ \t]*authenticator[ \t]*:[ \t]*/, "", line) + sub(/[,:].*$/, "", line) + print line + exit + } + ' "./conf/gremlin-server.yaml" +} + +# enable-auth.sh appends definitions to files it did not write. On a +# mounted config those appended definitions are duplicates the two parsers +# resolve in opposite directions — HugeConfig (commons-configuration) takes +# the first, snakeyaml takes the last — so Gremlin and REST can land on +# different authenticators with no error from either. Normalize both sides +# to one definition of the same authenticator here; enable-auth.sh's +# per-file guards then make its appends no-ops on anything already set. +align_auth_config() { + local rest_auth yaml_auth + + rest_auth=$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}") + yaml_auth=$(get_yaml_authenticator) + if [[ -n "${rest_auth}" && -n "${yaml_auth}" && "${rest_auth}" != "${yaml_auth}" ]]; then + log "WARN: REST and Gremlin name different authenticators" \ + "('${rest_auth}' vs '${yaml_auth}'); leaving both untouched" + return + fi + if [[ -z "${rest_auth}" && -z "${yaml_auth}" ]]; then + export AUTHENTICATOR_CLASS="org.apache.hugegraph.auth.StandardAuthenticator" + elif [[ -n "${yaml_auth}" ]]; then + set_prop_encoded "auth.authenticator" "${yaml_auth}" "${REST_SERVER_CONF}" + else + export AUTHENTICATOR_CLASS="${rest_auth}" + fi + # auth.graph_store and the gremlin.graph flip are left to enable-auth.sh, + # which appends/rewrites only what is absent or still the plain default. } migrate_env() { @@ -147,6 +197,7 @@ elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then fi if [[ -n "${PASSWORD:-}" ]]; then set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" + align_auth_config # This script is idempotent and must run outside the initialization guard: # an upgrade can preserve the marker from an unauthenticated deployment. ./bin/enable-auth.sh diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk new file mode 100644 index 0000000000..a7a3bde5e1 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -0,0 +1,227 @@ +# 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 `mode` environment variable: +# +# mode=get key=K file=F +# print the value of K's first logical definition +# mode=set key=K 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 PROP_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. +# +# Grammar implemented (java.util.Properties line reader + the +# first-definition-wins rule Configuration.getString applies): +# - '#' / '!' comments and blank lines +# - '=' / ':' / whitespace separators, 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. + +function die(msg) { + printf "props.awk: %s\n", msg > "/dev/stderr" + exit 1 +} + +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]*([#!]|$)/ +} + +# 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. +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") { 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]+/, "", rest) + c = substr(rest, 1, 1) + if (c == "=" || c == ":") rest = substr(rest, 2) + } + sub(/^[ \t]+/, "", rest) + V_RAW = rest +} + +# 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, nl, next_raw, start, logical) { + NLINES = 0 + while ((getline raw < file) > 0) { + NLINES++ + RAW[NLINES] = raw + } + close(file) + + NBLOCK = 0 + for (nl = 1; nl <= NLINES; nl++) { + raw = RAW[nl] + if (is_skipped(raw)) { + NBLOCK++ + BTYPE[NBLOCK] = "skip" + BFIRST[NBLOCK] = nl + BLAST[NBLOCK] = nl + continue + } + start = nl + logical = raw + while (trailing_backslashes(logical) % 2 == 1 && nl < NLINES) { + logical = substr(logical, 1, length(logical) - 1) + nl++ + next_raw = RAW[nl] + sub(/^[ \t]+/, "", next_raw) + logical = logical next_raw + } + split_kv(logical) + NBLOCK++ + BTYPE[NBLOCK] = "entry" + BFIRST[NBLOCK] = start + BLAST[NBLOCK] = nl + BKEY[NBLOCK] = unescape(K_RAW) + # 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, b, first, ln) { + props_load(file) + first = 0 + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) { + if (first == 0) first = b + else BDROP[b] = 1 + } + } + for (b = 1; b <= NBLOCK; b++) { + if (BDROP[b]) continue + if (b == first) { + printf "%s=%s\n", key, enc_val > file + } else { + for (ln = BFIRST[b]; ln <= BLAST[b]; ln++) + print RAW[ln] > file + } + } + if (first == 0) + printf "%s=%s\n", key, enc_val > file + close(file) +} + +function props_get(file, key, b) { + props_load(file) + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) { + print BVAL[b] + return + } + } +} + +BEGIN { + mode = ENVIRON["PROPS_MODE"] + key = ENVIRON["PROPS_KEY"] + file = ENVIRON["PROPS_FILE"] + if (file == "" || key == "") + die("PROPS_FILE and PROPS_KEY must be set") + if (mode == "get") { + props_get(file, key) + } else if (mode == "set") { + props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) + } else { + die("PROPS_MODE must be get or set") + } +} 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..badf92a4f7 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -23,10 +23,16 @@ entrypoint="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/docker-entrypoint.s test_dir="$(mktemp -d)" trap 'rm -rf "${test_dir}"' EXIT +# Eval the property helpers plus the PROPS_AWK location block they depend +# on. The entrypoint's top-level code hard-exits when props.awk is +# missing, so it cannot be sourced directly; anchor to the marker comment +# above the assignment instead. +PROPS_AWK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/props.awk" +export PROPS_AWK eval "$(awk ' /^encode_prop_value\(\) \{/ { capture = 1 } capture { print } - capture && /^\}$/ && ++function_ends == 3 { exit } + capture && /^\}$/ && ++function_ends == 4 { exit } ' "${entrypoint}")" assert_replaced() { @@ -66,3 +72,52 @@ 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}" 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..119be9f979 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 @@ -41,16 +41,34 @@ if [ ! -d "$BAK_CONF" ]; then 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" +fi + +# The appends below are guarded per file and match only an absent or still +# commented-out definition, so they are no-ops on any config that already +# carries authentication (e.g. a mounted one, or a re-run of this script). +# 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. +AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" +if ! grep -Eq '^[ \t]*authentication[ \t]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then sed -i -e '$a\authentication: {' \ - -e '$a\ authenticator: org.apache.hugegraph.auth.StandardAuthenticator,' \ + -e "\$a\\ authenticator: ${AUTHENTICATOR_CLASS}," \ -e '$a\ authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ -e '$a\ config: {tokens: conf/rest-server.properties}' \ -e '$a\}' ${CONF}/${GREMLIN_SERVER_CONF} +fi - sed -i -e '$a\auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator' \ - -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF} +if ! grep -Eq '^[ \t]*auth\.authenticator[ \t]*=' "${CONF}/${REST_SERVER_CONF}"; then + sed -i -e "\$a\\auth.authenticator=${AUTHENTICATOR_CLASS}" ${CONF}/${REST_SERVER_CONF} +fi + +if ! grep -Eq '^[ \t]*auth\.graph_store[ \t]*=' "${CONF}/${REST_SERVER_CONF}"; then + sed -i -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF} +fi - sed -i 's/gremlin.graph=org.apache.hugegraph.HugeFactory/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/g' ${CONF}/graphs/${GRAPH_CONF} +if grep -Eq '^gremlin\.graph[ \t]*=org\.apache\.hugegraph\.HugeFactory[ \t]*$' "${CONF}/graphs/${GRAPH_CONF}"; then + sed -i 's/^gremlin\.graph[ \t]*=org\.apache\.hugegraph\.HugeFactory[ \t]*$/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/' ${CONF}/graphs/${GRAPH_CONF} fi From f5e368cea2cabab7692bf36597096a7414844427 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Fri, 11 Sep 2026 22:31:36 +0530 Subject: [PATCH 02/22] fix(docker): close review gaps in auth bootstrap alignment Review follow-ups on the props.awk bootstrap: the yaml authenticator scalar now goes through a snakeyaml-shaped cleanup (inline comments, quotes and padding stripped) instead of only cutting at the first comma or colon; a flow mapping on the authentication line itself is read, and an authentication block without a readable authenticator takes the WARN branch instead of the both-empty default. props.awk strips leading whitespace before the key the way java.util.Properties does, so an indented key is rewritten in place rather than duplicated. enable-auth.sh's append guards now accept the ':', bare-whitespace and backslash-escaped spellings with [[:blank:]] classes (the '[ \t]' bracket matched space, backslash and the letter t), and the gremlin.graph flip embeds the carriage return as a byte because GNU grep reads \r in a pattern as the letter r, which made the anchored guard drop mounted CRLF configs. Test docs name the environment variables and the function count they rely on, and new regression tests cover indented keys, yaml scalar cleanup, flow mappings and the block-without-authenticator WARN. --- .../docker/docker-entrypoint.sh | 64 +++++++++++++-- .../hugegraph-dist/docker/props.awk | 13 +++- .../docker/test/test-docker-entrypoint.sh | 77 ++++++++++++++++--- .../src/assembly/static/bin/enable-auth.sh | 21 +++-- 4 files changed, 149 insertions(+), 26 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index ee2994776c..ce44f35915 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -80,24 +80,69 @@ get_prop_encoded() { } # First uncommented `authenticator:` inside the gremlin-server.yaml -# authentication block. snakeyaml resolves duplicate top-level keys to the -# last one, but a mounted file carrying two authentication blocks is -# pathological; report the first and let the mismatch WARN handle it. +# authentication block, or on the `authentication:` line itself (a flow +# mapping). snakeyaml resolves duplicate top-level keys to the last one, +# but a mounted file carrying two authentication blocks is pathological; +# report the first and let the mismatch WARN handle it. The scalar is +# cleaned the way snakeyaml reads it — an inline comment (a '#' preceded +# by whitespace), surrounding quotes and padding are stripped — because +# java.util.Properties keeps all of those in the class name. get_yaml_authenticator() { local yaml="./conf/gremlin-server.yaml" [[ -f "${yaml}" ]] || return 0 awk ' + function scalar(s, out, i, n, c, q) { + out = "" + q = "" + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + if (c == q) q = "" + else out = out c + continue + } + if (c == "\"" || c == "\047") { q = c; continue } + if (c == "#" && + (out == "" || substr(out, length(out), 1) ~ /[ \t]/)) + break + if (c == "," || c == "}" || c == "]") break + out = out c + } + sub(/^[ \t\r]+/, "", out) + sub(/[ \t\r]+$/, "", out) + return out + } /^[ \t]*#/ { next } - /^[ \t]*authentication[ \t]*:/ { inblk = 1; next } + /^[ \t]*authentication[ \t]*:/ { + inblk = 1 + line = $0 + sub(/^[ \t]*authentication[ \t]*:[ \t]*/, "", line) + if (match(line, /authenticator[ \t]*:/)) { + print scalar(substr(line, RSTART + RLENGTH)) + exit + } + next + } inblk && /^[ \t]+authenticator[ \t]*:/ { line = $0 sub(/^[ \t]*authenticator[ \t]*:[ \t]*/, "", line) - sub(/[,:].*$/, "", line) - print line + print scalar(line) exit } - ' "./conf/gremlin-server.yaml" + ' "${yaml}" +} + +# A mounted yaml can carry an authentication block whose authenticator +# cannot be read (an empty or unparseable one). That is not the +# both-empty case: exporting the default would override an explicit +# choice that snakeyaml does resolve, so callers treat it as a mismatch. +has_yaml_authentication_block() { + local yaml="./conf/gremlin-server.yaml" + + [[ -f "${yaml}" ]] || return 1 + grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${yaml}" } # enable-auth.sh appends definitions to files it did not write. On a @@ -112,6 +157,11 @@ align_auth_config() { rest_auth=$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}") yaml_auth=$(get_yaml_authenticator) + if [[ -z "${yaml_auth}" ]] && has_yaml_authentication_block; then + log "WARN: gremlin-server.yaml carries an authentication block" \ + "without a readable authenticator; leaving both sides untouched" + return + fi if [[ -n "${rest_auth}" && -n "${yaml_auth}" && "${rest_auth}" != "${yaml_auth}" ]]; then log "WARN: REST and Gremlin name different authenticators" \ "('${rest_auth}' vs '${yaml_auth}'); leaving both untouched" diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index a7a3bde5e1..738efced25 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -20,14 +20,14 @@ # lines and duplicate definitions differently, which is how a mounted # config ends up with two definitions of one key. # -# One invocation, selected with the `mode` environment variable: +# One invocation, selected with the `PROPS_MODE` environment variable: # -# mode=get key=K file=F +# PROPS_MODE=get PROPS_KEY=K PROPS_FILE=F # print the value of K's first logical definition -# mode=set key=K file=F +# 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 PROP_VALUE_ENCODED (an environment +# 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. @@ -164,6 +164,11 @@ function props_load(file, raw, nl, next_raw, start, logical) { sub(/^[ \t]+/, "", 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]+/, "", logical) split_kv(logical) NBLOCK++ BTYPE[NBLOCK] = "entry" 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 badf92a4f7..386e70ed62 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -23,17 +23,21 @@ entrypoint="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/docker-entrypoint.s test_dir="$(mktemp -d)" trap 'rm -rf "${test_dir}"' EXIT -# Eval the property helpers plus the PROPS_AWK location block they depend -# on. The entrypoint's top-level code hard-exits when props.awk is -# missing, so it cannot be sourced directly; anchor to the marker comment -# above the assignment instead. +# 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_yaml_authenticator has_yaml_authentication_block align_auth_config; do + eval "$(awk -v fn="${fn}" ' + index($0, fn "() {") == 1 { capture = 1 } + capture { print } + capture && /^}$/ { exit } + ' "${entrypoint}")" +done +log() { echo "[hugegraph-server-entrypoint] $*"; } PROPS_AWK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/props.awk" export PROPS_AWK -eval "$(awk ' - /^encode_prop_value\(\) \{/ { capture = 1 } - capture { print } - capture && /^\}$/ && ++function_ends == 4 { exit } -' "${entrypoint}")" assert_replaced() { local separator="$1" @@ -121,3 +125,58 @@ printf '%s\n' \ 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}" + +# get_yaml_authenticator must agree with snakeyaml on what a mounted +# gremlin-server.yaml says: the authenticator inside the authentication +# block — quoted scalars and inline comments cleaned the way snakeyaml +# strips them — and a flow mapping on the authentication line itself. +# align_auth_config must not read an authentication block without a +# readable authenticator as "no yaml side": exporting the default there +# would override an explicit choice, so both sides stay untouched. +yaml_dir="${test_dir}/yaml" +mkdir -p "${yaml_dir}/conf" +( + cd "${yaml_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + : > "${REST_SERVER_CONF}" + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: "com.example.MyAuth" # custom' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > conf/gremlin-server.yaml + [[ "$(get_yaml_authenticator)" == "com.example.MyAuth" ]] + + printf '%s\n' \ + 'authentication: {authenticator: com.example.FlowAuth, authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler, config: {tokens: conf/rest-server.properties}}' \ + > conf/gremlin-server.yaml + [[ "$(get_yaml_authenticator)" == "com.example.FlowAuth" ]] + + printf '%s\n' \ + 'authentication:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > conf/gremlin-server.yaml + unset AUTHENTICATOR_CLASS + align_auth_config + [[ -z "${AUTHENTICATOR_CLASS:-}" ]] + [[ ! -s "${REST_SERVER_CONF}" ]] + + printf '%s\n' \ + 'authentication:' \ + ' authenticator: com.example.YamlAuth' \ + > conf/gremlin-server.yaml + align_auth_config + grep -q '^auth\.authenticator=com\.example\.YamlAuth$' "${REST_SERVER_CONF}" +) 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 119be9f979..e6a3c01513 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 @@ -46,14 +46,18 @@ fi # The appends below are guarded per file and match only an absent or still # commented-out definition, so they are no-ops on any config that already # carries authentication (e.g. a mounted one, or a re-run of this script). -# Appending unconditionally used to create duplicate definitions that the +# The guards accept every spelling java.util.Properties reads as the key — +# '=' or ':' or bare-whitespace separators, leading whitespace and +# backslash-escaped dots — and the gremlin.graph flip tolerates CRLF +# endings, which a mounted config saved on Windows carries. 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. AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" -if ! grep -Eq '^[ \t]*authentication[ \t]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then +if ! grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then sed -i -e '$a\authentication: {' \ -e "\$a\\ authenticator: ${AUTHENTICATOR_CLASS}," \ -e '$a\ authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ @@ -61,14 +65,19 @@ if ! grep -Eq '^[ \t]*authentication[ \t]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; t -e '$a\}' ${CONF}/${GREMLIN_SERVER_CONF} fi -if ! grep -Eq '^[ \t]*auth\.authenticator[ \t]*=' "${CONF}/${REST_SERVER_CONF}"; then +if ! grep -Eq '^[[:blank:]]*auth[\\]?\.authenticator[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then sed -i -e "\$a\\auth.authenticator=${AUTHENTICATOR_CLASS}" ${CONF}/${REST_SERVER_CONF} fi -if ! grep -Eq '^[ \t]*auth\.graph_store[ \t]*=' "${CONF}/${REST_SERVER_CONF}"; then +if ! grep -Eq '^[[:blank:]]*auth[\\]?\.graph_store[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then sed -i -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF} fi -if grep -Eq '^gremlin\.graph[ \t]*=org\.apache\.hugegraph\.HugeFactory[ \t]*$' "${CONF}/graphs/${GRAPH_CONF}"; then - sed -i 's/^gremlin\.graph[ \t]*=org\.apache\.hugegraph\.HugeFactory[ \t]*$/gremlin.graph=org.apache.hugegraph.auth.HugeFactoryAuthProxy/' ${CONF}/graphs/${GRAPH_CONF} +# GNU grep reads \r in a pattern as the letter r, so the carriage return a +# CRLF line ends with is embedded as a byte: without it the anchored guard +# misses a mounted CRLF config and the factory is never wrapped for auth +# although both servers already believe authentication is on. +CR=$'\r' +if grep -Eq "^gremlin\\.graph[[:blank:]]*=org\\.apache\\.hugegraph\\.HugeFactory[[:blank:]]*${CR}?\$" "${CONF}/graphs/${GRAPH_CONF}"; then + sed -i 's/^\(gremlin\.graph[[:blank:]]*=[[:blank:]]*\)org\.apache\.hugegraph\.HugeFactory/\1org.apache.hugegraph.auth.HugeFactoryAuthProxy/' "${CONF}/graphs/${GRAPH_CONF}" fi From 5f5051130d29089e34fec1d611d52efa5ba566b6 Mon Sep 17 00:00:00 2001 From: Adarsh-Me Date: Sat, 12 Sep 2026 09:07:14 +0000 Subject: [PATCH 03/22] fix(docker): close review gaps in auth bootstrap parsing and rewrite Address review 5185689081 on the auth bootstrap alignment: - props.awk: strip one trailing CR while assembling logical lines so CRLF configs parse like java.util.Properties, without touching the RAW bytes replayed on rewrite; add get-decoded mode. - props.awk: die when getline fails and rewrite atomically through a sibling temp file renamed over the original. - enable-auth.sh: widen the gremlin.graph guard and flip together for colon, equals, bare-whitespace, leading-blank and escaped-dot spellings with optional CR, still skipping proxied/commented lines. - docker-entrypoint.sh: compare the unescaped authenticator with the yaml scalar and write the yaml side through the encoding setter. Add CRLF plus escaped-authenticator regression cases to test-docker-entrypoint.sh. --- .../docker/docker-entrypoint.sh | 15 +++++- .../hugegraph-dist/docker/props.awk | 51 +++++++++++++++---- .../docker/test/test-docker-entrypoint.sh | 46 ++++++++++++++++- .../src/assembly/static/bin/enable-auth.sh | 4 +- 4 files changed, 101 insertions(+), 15 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index ce44f35915..bff61f6977 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -79,6 +79,17 @@ get_prop_encoded() { awk -f "${PROPS_AWK}" /dev/null } +# Decoded read: unescapes the on-disk value the way java.util.Properties +# does, so it compares equal with the snakeyaml-decoded scalar from +# get_yaml_authenticator. The raw get_prop_encoded mode stays for the +# secret round trip, which must replay backslashes byte-for-byte. +get_prop() { + local key="$1" file="$2" + + PROPS_MODE=get-decoded PROPS_KEY="${key}" PROPS_FILE="${file}" \ + awk -f "${PROPS_AWK}" /dev/null +} + # First uncommented `authenticator:` inside the gremlin-server.yaml # authentication block, or on the `authentication:` line itself (a flow # mapping). snakeyaml resolves duplicate top-level keys to the last one, @@ -155,7 +166,7 @@ has_yaml_authentication_block() { align_auth_config() { local rest_auth yaml_auth - rest_auth=$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}") + rest_auth=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") yaml_auth=$(get_yaml_authenticator) if [[ -z "${yaml_auth}" ]] && has_yaml_authentication_block; then log "WARN: gremlin-server.yaml carries an authentication block" \ @@ -170,7 +181,7 @@ align_auth_config() { if [[ -z "${rest_auth}" && -z "${yaml_auth}" ]]; then export AUTHENTICATOR_CLASS="org.apache.hugegraph.auth.StandardAuthenticator" elif [[ -n "${yaml_auth}" ]]; then - set_prop_encoded "auth.authenticator" "${yaml_auth}" "${REST_SERVER_CONF}" + set_prop "auth.authenticator" "${yaml_auth}" "${REST_SERVER_CONF}" else export AUTHENTICATOR_CLASS="${rest_auth}" fi diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index 738efced25..10cb0dc7eb 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -135,20 +135,32 @@ function split_kv(s, n, i, c, esc, sep_at, rest) { V_RAW = rest } +function shquote(s) { + gsub(/'/, "'\\''", s) + return "'" s "'" +} + # 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, nl, next_raw, start, logical) { +function props_load(file, raw, rc, nl, stripped, next_raw, start, logical) { NLINES = 0 - while ((getline raw < file) > 0) { + while ((rc = (getline raw < file)) > 0) { NLINES++ RAW[NLINES] = raw } + if (rc == -1) + die("cannot read " file) close(file) NBLOCK = 0 for (nl = 1; nl <= NLINES; nl++) { raw = RAW[nl] - if (is_skipped(raw)) { + # CRLF: java.util.Properties drops the line terminator, so one + # trailing CR is stripped for parsing only. RAW[] keeps the byte + # so props_set replays untouched lines byte-for-byte. + stripped = raw + sub(/\r$/, "", stripped) + if (is_skipped(stripped)) { NBLOCK++ BTYPE[NBLOCK] = "skip" BFIRST[NBLOCK] = nl @@ -156,11 +168,12 @@ function props_load(file, raw, nl, next_raw, start, logical) { continue } start = nl - logical = raw + logical = stripped while (trailing_backslashes(logical) % 2 == 1 && nl < NLINES) { logical = substr(logical, 1, length(logical) - 1) nl++ next_raw = RAW[nl] + sub(/\r$/, "", next_raw) sub(/^[ \t]+/, "", next_raw) logical = logical next_raw } @@ -183,7 +196,7 @@ function props_load(file, raw, nl, next_raw, start, logical) { } } -function props_set(file, key, enc_val, b, first, ln) { +function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { props_load(file) first = 0 for (b = 1; b <= NBLOCK; b++) { @@ -192,18 +205,24 @@ function props_set(file, key, enc_val, b, first, ln) { else BDROP[b] = 1 } } + # Atomic rewrite: the original is never truncated. Everything lands + # in a sibling temp file that is closed and renamed over the original. + tmp = file ".tmp" for (b = 1; b <= NBLOCK; b++) { if (BDROP[b]) continue if (b == first) { - printf "%s=%s\n", key, enc_val > file + printf "%s=%s\n", key, enc_val > tmp } else { for (ln = BFIRST[b]; ln <= BLAST[b]; ln++) - print RAW[ln] > file + print RAW[ln] > tmp } } if (first == 0) - printf "%s=%s\n", key, enc_val > file - close(file) + printf "%s=%s\n", key, enc_val > tmp + close(tmp) + cmd = "mv -- " shquote(tmp) " " shquote(file) + if (system(cmd) != 0) + die("cannot rename " tmp " over " file) } function props_get(file, key, b) { @@ -216,6 +235,16 @@ function props_get(file, key, b) { } } +function props_get_decoded(file, key, b) { + props_load(file) + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) { + print unescape(BVAL[b]) + return + } + } +} + BEGIN { mode = ENVIRON["PROPS_MODE"] key = ENVIRON["PROPS_KEY"] @@ -224,9 +253,11 @@ BEGIN { die("PROPS_FILE and PROPS_KEY must be set") if (mode == "get") { props_get(file, key) + } else if (mode == "get-decoded") { + props_get_decoded(file, key) } else if (mode == "set") { props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) } else { - die("PROPS_MODE must be get or set") + die("PROPS_MODE must be get, get-decoded or set") } } 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 386e70ed62..51eabe5746 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -27,7 +27,7 @@ trap 'rm -rf "${test_dir}"' EXIT # 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 \ +for fn in encode_prop_value set_prop_encoded set_prop get_prop_encoded get_prop \ get_yaml_authenticator has_yaml_authentication_block align_auth_config; do eval "$(awk -v fn="${fn}" ' index($0, fn "() {") == 1 { capture = 1 } @@ -180,3 +180,47 @@ mkdir -p "${yaml_dir}/conf" align_auth_config grep -q '^auth\.authenticator=com\.example\.YamlAuth$' "${REST_SERVER_CONF}" ) + +# 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}" +printf 'pd.peers=a,\\\r\n 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" ]] +[[ "$(get_prop 'auth.authenticator' "${crlf_file}")" == \ + "org.apache.hugegraph.auth.StandardAuthenticator" ]] +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 ! grep -q $'^unrelated=true\r$' "${crlf_file}"; then + echo "CRLF bytes of untouched lines must be preserved" >&2 + exit 1 +fi + +# An escaped authenticator and a plain yaml scalar name the same class: +# the comparison unescapes first, so no spurious WARN and no skipped +# alignment. +escaped_auth_dir="${test_dir}/yaml-escaped-auth" +mkdir -p "${escaped_auth_dir}/conf" +( + cd "${escaped_auth_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + printf '%s\n' \ + 'auth.authenticator=org.apache.hugegraph.auth\.StandardAuthenticator' \ + > "${REST_SERVER_CONF}" + printf '%s\n' \ + 'authentication:' \ + ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ + > conf/gremlin-server.yaml + unset AUTHENTICATOR_CLASS + align_out=$(align_auth_config 2>&1) + [[ -z "${AUTHENTICATOR_CLASS:-}" ]] + [[ "${align_out}" != *"different authenticators"* ]] + grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + "${REST_SERVER_CONF}" +) 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 e6a3c01513..8524894f26 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 @@ -78,6 +78,6 @@ fi # misses a mounted CRLF config and the factory is never wrapped for auth # although both servers already believe authentication is on. CR=$'\r' -if grep -Eq "^gremlin\\.graph[[:blank:]]*=org\\.apache\\.hugegraph\\.HugeFactory[[:blank:]]*${CR}?\$" "${CONF}/graphs/${GRAPH_CONF}"; then - sed -i 's/^\(gremlin\.graph[[:blank:]]*=[[:blank:]]*\)org\.apache\.hugegraph\.HugeFactory/\1org.apache.hugegraph.auth.HugeFactoryAuthProxy/' "${CONF}/graphs/${GRAPH_CONF}" +if grep -Eq "^[[:blank:]]*gremlin[\\\\]?\\.graph[[:blank:]]*([:=]|[[:blank:]])[[:blank:]]*org\\.apache\\.hugegraph\\.HugeFactory[[:blank:]]*${CR}?$" "${CONF}/graphs/${GRAPH_CONF}"; then + sed -i -E "s#^([[:blank:]]*gremlin[\\\\]?\\.graph[[:blank:]]*([:=]|[[:blank:]])[[:blank:]]*)org\\.apache\\.hugegraph\\.HugeFactory#\\1org.apache.hugegraph.auth.HugeFactoryAuthProxy#" "${CONF}/graphs/${GRAPH_CONF}" fi From bedc21eb85631185d5d09fff2e8e03a898ae9de0 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Sun, 13 Sep 2026 11:50:43 +0530 Subject: [PATCH 04/22] fix(docker): keep the config inode when rewriting properties The staged temp file was renamed over the config, replacing its inode: a 0600 config holding secrets came back umask-world-readable, a symlinked config was replaced by a regular file, and a config bind-mounted as a single file could not be renamed over at all (rename(2) returns EBUSY on a mount point), aborting the entrypoint on exactly the mounted configs this path exists for. The temp file is now copied back onto the original instead, which keeps the inode, mode, symlink and mount point, and is created 0600 itself since it can hold secrets while it exists. Regression tests check that a 0600 file keeps its mode and that a symlink survives a set with its target rewritten. --- .../hugegraph-dist/docker/props.awk | 19 +++++++++++++---- .../docker/test/test-docker-entrypoint.sh | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index 10cb0dc7eb..29b214d9b5 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -205,8 +205,9 @@ function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { else BDROP[b] = 1 } } - # Atomic rewrite: the original is never truncated. Everything lands - # in a sibling temp file that is closed and renamed over the original. + # Staged rewrite: everything lands in a sibling temp file first, so a + # failure before the copy-back leaves the original untouched. The temp + # file can hold secrets, so it is created 0600 regardless of the umask. tmp = file ".tmp" for (b = 1; b <= NBLOCK; b++) { if (BDROP[b]) continue @@ -220,9 +221,19 @@ function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { if (first == 0) printf "%s=%s\n", key, enc_val > tmp close(tmp) - cmd = "mv -- " shquote(tmp) " " shquote(file) + # 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, and since the temp file is fully written + # before the original is truncated, a failed copy still leaves the + # previous content on disk. + system("chmod 600 -- " shquote(tmp)) + cmd = "cat -- " shquote(tmp) " > " shquote(file) " && rm -f -- " shquote(tmp) if (system(cmd) != 0) - die("cannot rename " tmp " over " file) + die("cannot copy " tmp " back over " file) } function props_get(file, key, b) { 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 51eabe5746..7304e05f44 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -224,3 +224,24 @@ mkdir -p "${escaped_auth_dir}/conf" grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ "${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}" +[[ "$(stat -c '%a' "${mode_file}")" == "600" ]] +grep -q '^init_store\.enabled=true$' "${mode_file}" +grep -q '^unrelated=true$' "${mode_file}" +[[ ! -e "${mode_file}.tmp" ]] + +target_file="${test_dir}/config-target" +link_file="${test_dir}/config-link" +printf '%s\n' 'unrelated=true' > "${target_file}" +ln -s "${target_file}" "${link_file}" +set_prop "init_store.enabled" "true" "${link_file}" +[[ -L "${link_file}" ]] +grep -q '^init_store\.enabled=true$' "${target_file}" From bf2718f54b9df8bb5175c86b29782ca17d5f6023 Mon Sep 17 00:00:00 2001 From: Oracle Public Cloud User Date: Wed, 16 Sep 2026 13:26:23 +0000 Subject: [PATCH 05/22] fix(docker): refuse one-sided auth bootstrap and pre-create tmp 0600 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unreadable-authenticator yaml block: align_auth_config now fails the entrypoint instead of logging 'leaving both sides untouched' while enable-auth.sh goes on to write the REST side alone (REST on StandardAuthenticator vs Gremlin on AllowAllAuthenticator). The error tells the operator to add an 'authenticator:' entry or remove the block. props.awk: pre-create the rewrite temp file 0600 (umask 077) before the first write so secrets never sit briefly umask-readable; the chmod after close is kept for stale tmp files from crashed runs. Tests: the unreadable-block case now asserts refusal, and a new case runs enable-auth.sh against the same layout to prove it would write only REST (yaml untouched, graph flipped) — i.e. what the refusal prevents. --- .../docker/docker-entrypoint.sh | 16 ++++-- .../hugegraph-dist/docker/props.awk | 8 ++- .../docker/test/test-docker-entrypoint.sh | 54 +++++++++++++++++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index bff61f6977..63f2c9cd07 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -169,9 +169,17 @@ align_auth_config() { rest_auth=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") yaml_auth=$(get_yaml_authenticator) if [[ -z "${yaml_auth}" ]] && has_yaml_authentication_block; then - log "WARN: gremlin-server.yaml carries an authentication block" \ - "without a readable authenticator; leaving both sides untouched" - return + # Refuse instead of bootstrapping one side: enable-auth.sh runs right + # after align and only touches the REST side, so continuing would put + # REST on StandardAuthenticator while Gremlin stays on TinkerPop's + # AllowAllAuthenticator default. Failing fast (rather than skipping + # enable-auth.sh) keeps a PASSWORD deployment from starting with + # authentication silently half-applied. + log "ERROR: gremlin-server.yaml carries an authentication block" \ + "without a readable authenticator; refusing to bootstrap" \ + "authentication one-sided. Add an 'authenticator:' entry to" \ + "the block or remove the block, then restart." + return 1 fi if [[ -n "${rest_auth}" && -n "${yaml_auth}" && "${rest_auth}" != "${yaml_auth}" ]]; then log "WARN: REST and Gremlin name different authenticators" \ @@ -258,6 +266,8 @@ elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then fi if [[ -n "${PASSWORD:-}" ]]; then set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" + # A refusal inside align_auth_config exits the entrypoint under set -e, + # so enable-auth.sh can never run one-sided after it. align_auth_config # This script is idempotent and must run outside the initialization guard: # an upgrade can preserve the marker from an unauthenticated deployment. diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index 29b214d9b5..621cd480a8 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -207,8 +207,14 @@ function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { } # Staged rewrite: everything lands in a sibling temp file first, so a # failure before the copy-back leaves the original untouched. The temp - # file can hold secrets, so it is created 0600 regardless of the umask. + # file can hold secrets, so it is pre-created 0600 before the first + # write: awk's `>` below would otherwise create it under the process + # umask (usually 0644), leaving auth.admin_pa or auth.token_secret + # briefly group- and world-readable. Truncating an existing file keeps + # its mode, and the chmod after close repairs a stale tmp left behind + # by a crashed run. tmp = file ".tmp" + system("umask 077 && : > " shquote(tmp)) for (b = 1; b <= NBLOCK; b++) { if (BDROP[b]) continue if (b == first) { 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 7304e05f44..012cf8907a 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -142,9 +142,10 @@ assert_line_count 1 '^unrelated=true$' "${indented_file}" # gremlin-server.yaml says: the authenticator inside the authentication # block — quoted scalars and inline comments cleaned the way snakeyaml # strips them — and a flow mapping on the authentication line itself. -# align_auth_config must not read an authentication block without a -# readable authenticator as "no yaml side": exporting the default there -# would override an explicit choice, so both sides stay untouched. +# align_auth_config refuses an authentication block without a readable +# authenticator instead of treating it as "no yaml side": exporting the +# default there would override an explicit choice, and continuing would let +# enable-auth.sh write the REST side alone. yaml_dir="${test_dir}/yaml" mkdir -p "${yaml_dir}/conf" ( @@ -164,12 +165,20 @@ mkdir -p "${yaml_dir}/conf" > conf/gremlin-server.yaml [[ "$(get_yaml_authenticator)" == "com.example.FlowAuth" ]] +# align_auth_config must refuse an authentication block without a readable +# authenticator: continuing would let enable-auth.sh write the REST side +# alone (REST on StandardAuthenticator, Gremlin on TinkerPop's +# AllowAllAuthenticator default), so the entrypoint stops here instead. printf '%s\n' \ 'authentication:' \ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ > conf/gremlin-server.yaml unset AUTHENTICATOR_CLASS - align_auth_config + if align_auth_config; then + echo "align_auth_config must refuse an authentication block" \ + "without a readable authenticator" >&2 + exit 1 + fi [[ -z "${AUTHENTICATOR_CLASS:-}" ]] [[ ! -s "${REST_SERVER_CONF}" ]] @@ -181,6 +190,43 @@ mkdir -p "${yaml_dir}/conf" grep -q '^auth\.authenticator=com\.example\.YamlAuth$' "${REST_SERVER_CONF}" ) +# The refusal above is what keeps enable-auth.sh from writing one side: +# against the same ambiguous layout, enable-auth.sh on its own writes only +# the REST file (its yaml guard already sees an `authentication:` line), +# leaving REST on StandardAuthenticator and Gremlin on TinkerPop's +# AllowAllAuthenticator default. The entrypoint never lets it run there +# because align_auth_config fails first under set -e. +onesided_dir="${test_dir}/yaml-onesided" +mkdir -p "${onesided_dir}/bin" "${onesided_dir}/conf/graphs" +cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ + "${onesided_dir}/bin/enable-auth.sh" +chmod +x "${onesided_dir}/bin/enable-auth.sh" +( + cd "${onesided_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + : > conf/rest-server.properties + printf '%s\n' \ + 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ + > conf/graphs/hugegraph.properties + printf '%s\n' \ + 'authentication:' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + > conf/gremlin-server.yaml + unset AUTHENTICATOR_CLASS + if align_auth_config; then + echo "align_auth_config must refuse an authentication block without a readable authenticator" >&2 + exit 1 + fi + ./bin/enable-auth.sh + grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ + conf/rest-server.properties + grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties + if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then + echo "enable-auth.sh must not add an authenticator to the yaml block" >&2 + exit 1 + fi +) + # 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. From b93b52e5756ae5708f09c421a4527b70a87cfc72 Mon Sep 17 00:00:00 2001 From: Adarsh Mishra <122873385+Adarsh-Me@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:56:19 +0530 Subject: [PATCH 06/22] fix(docker): close the remaining auth bootstrap review gaps Four review findings on the auth bootstrap, each with a case that fails without the change: - enable-auth.sh: the `sed -i '$a\...'` appends were silent no-ops on a file with no lines, so an empty mounted config received neither `auth.authenticator` nor the yaml `authentication:` block while the entrypoint had already applied PASSWORD and init-store had run in auth mode. Append with `>>`, closing a missing trailing newline first. This is the failure that red `docker-build (hugegraph-server/Dockerfile)` reports on run 35101931936. - props.awk: `cat tmp > file` truncates the destination before cat writes, so a mid-copy failure (ENOSPC, EIO) left a half-written config on disk rather than the previous content. Snapshot the original under umask 077 first, restore it when the copy fails, and only drop both staging files once the copy has succeeded. - docker-entrypoint.sh: get_yaml_authenticator opened its block on `authentication:` and never closed it, so an `authenticator:` belonging to any later mapping was read as Gremlin's and then written into the REST config. Track the key's indentation and end the block at the next key at or left of it. - docker-entrypoint.sh: the both-empty branch assigned the default authenticator unconditionally, discarding an operator-supplied AUTHENTICATOR_CLASS before enable-auth.sh could use it. It now only fills the value in when unset. --- .../docker/docker-entrypoint.sh | 16 +- .../hugegraph-dist/docker/props.awk | 34 +++- .../docker/test/test-docker-entrypoint.sh | 171 ++++++++++++++++++ .../src/assembly/static/bin/enable-auth.sh | 32 +++- 4 files changed, 238 insertions(+), 15 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 63f2c9cd07..fde8d253e0 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -128,6 +128,7 @@ get_yaml_authenticator() { /^[ \t]*#/ { next } /^[ \t]*authentication[ \t]*:/ { inblk = 1 + indent = match($0, /[^ \t]/) line = $0 sub(/^[ \t]*authentication[ \t]*:[ \t]*/, "", line) if (match(line, /authenticator[ \t]*:/)) { @@ -136,6 +137,15 @@ get_yaml_authenticator() { } next } + # A blank line does not close a YAML mapping. + /^[ \t\r]*$/ { next } + # The authenticator has to belong to the authentication mapping: + # any key at or left of that key is a sibling, so the block is + # over. Without this, the first `authenticator:` anywhere below + # `authentication:` is taken as the Gremlin one, which lets a + # later top-level mapping carrying its own authenticator decide + # the REST side too. + inblk && match($0, /[^ \t]/) <= indent { inblk = 0 } inblk && /^[ \t]+authenticator[ \t]*:/ { line = $0 sub(/^[ \t]*authenticator[ \t]*:[ \t]*/, "", line) @@ -187,7 +197,11 @@ align_auth_config() { return fi if [[ -z "${rest_auth}" && -z "${yaml_auth}" ]]; then - export AUTHENTICATOR_CLASS="org.apache.hugegraph.auth.StandardAuthenticator" + # Only fill in a default: an operator-supplied AUTHENTICATOR_CLASS + # is the intent for a config that names no authenticator yet, and + # assigning here would turn it back into StandardAuthenticator + # before enable-auth.sh ever saw it. + export AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" elif [[ -n "${yaml_auth}" ]]; then set_prop "auth.authenticator" "${yaml_auth}" "${REST_SERVER_CONF}" else diff --git a/hugegraph-server/hugegraph-dist/docker/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index 621cd480a8..1680f368c7 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -196,7 +196,7 @@ function props_load(file, raw, rc, nl, stripped, next_raw, start, logical) { } } -function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { +function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg) { props_load(file) first = 0 for (b = 1; b <= NBLOCK; b++) { @@ -233,13 +233,33 @@ function props_set(file, key, enc_val, tmp, cmd, b, first, ln) { # 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, and since the temp file is fully written - # before the original is truncated, a failed copy still leaves the - # previous content on disk. + # 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 (under + # umask 077 so a backup of a 0644 mounted config never ends up more + # permissive than it started, and chmodded in case a crashed run left + # one behind) and put it back when the copy fails. system("chmod 600 -- " shquote(tmp)) - cmd = "cat -- " shquote(tmp) " > " shquote(file) " && rm -f -- " shquote(tmp) - if (system(cmd) != 0) - die("cannot copy " tmp " back over " file) + bak = file ".bak" + cmd = "umask 077 && cp -- " shquote(file) " " shquote(bak) + if (system(cmd " && chmod 600 -- " shquote(bak)) != 0) + die("cannot back up " file " before the copy-back") + cmd = "cat -- " shquote(tmp) " > " shquote(file) + if (system(cmd) != 0) { + # Best effort: the destination is already 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. + msg = "cannot copy " tmp " over " file + 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, b) { 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 012cf8907a..3b225d30d7 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -283,6 +283,7 @@ 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" ]] target_file="${test_dir}/config-target" link_file="${test_dir}/config-link" @@ -291,3 +292,173 @@ ln -s "${target_file}" "${link_file}" set_prop "init_store.enabled" "true" "${link_file}" [[ -L "${link_file}" ]] grep -q '^init_store\.enabled=true$' "${target_file}" + +# An `authenticator:` below a *sibling* mapping is not the Gremlin one. +# `get_yaml_authenticator` opens its block on `authentication:` and has to +# close it again on the next key at the same indentation, or the yaml below +# reports com.example.TlsOnly — and align_auth_config then writes that +# class into rest-server.properties, so REST authenticates with a class the +# operator only ever mentioned to an unrelated mapping. +scope_dir="${test_dir}/yaml-scope" +mkdir -p "${scope_dir}/conf" +( + cd "${scope_dir}" || exit 1 + + printf '%s\n' \ + 'authentication:' \ + ' config: {tokens: conf/rest-server.properties}' \ + 'ssl:' \ + ' authenticator: com.example.TlsOnly' \ + > conf/gremlin-server.yaml + [[ -z "$(get_yaml_authenticator)" ]] + + # The block's own authenticator is still found when a sibling follows + # it, and one deeper than the key is still inside it. + printf '%s\n' \ + 'authentication:' \ + ' authenticator: com.example.GremlinAuth' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ + 'ssl:' \ + ' authenticator: com.example.TlsOnly' \ + > conf/gremlin-server.yaml + [[ "$(get_yaml_authenticator)" == "com.example.GremlinAuth" ]] + + # A blank line does not close a YAML mapping, and neither does a + # comment — including one that names an authenticator. + printf '%s\n' \ + 'authentication:' \ + '' \ + '# authenticator: com.example.CommentedAuth' \ + ' authenticator: com.example.BlankLineAuth' \ + > conf/gremlin-server.yaml + [[ "$(get_yaml_authenticator)" == "com.example.BlankLineAuth" ]] + + # Same indentation as the key means a sibling, not a member: the last + # case a mounted file is likely to get wrong, because a two-space + # `authentication:` under a top-level key is how some deployments + # indent the whole block. + printf '%s\n' \ + ' authentication:' \ + ' authenticator: com.example.IndentedAuth' \ + ' ssl:' \ + ' authenticator: com.example.TlsOnly' \ + > conf/gremlin-server.yaml + [[ "$(get_yaml_authenticator)" == "com.example.IndentedAuth" ]] +) + +# Both sides silent means "bootstrap authentication", but an operator who +# passed AUTHENTICATOR_CLASS named the class they want. The default may +# fill that in, it may not overwrite it: enable-auth.sh appends the value +# it is given, so overwriting here put StandardAuthenticator into a +# deployment that asked for something else. +class_dir="${test_dir}/authenticator-class" +mkdir -p "${class_dir}/conf" +( + cd "${class_dir}" || exit 1 + REST_SERVER_CONF="./conf/rest-server.properties" + : > "${REST_SERVER_CONF}" + printf '%s\n' 'restserver.url=http://0.0.0.0:8080' > conf/gremlin-server.yaml + + AUTHENTICATOR_CLASS=com.example.OperatorAuth + export AUTHENTICATOR_CLASS + align_auth_config + [[ "${AUTHENTICATOR_CLASS}" == "com.example.OperatorAuth" ]] + + unset AUTHENTICATOR_CLASS + align_auth_config + [[ "${AUTHENTICATOR_CLASS}" == \ + "org.apache.hugegraph.auth.StandardAuthenticator" ]] +) + +# 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" +cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ + "${empty_dir}/bin/enable-auth.sh" +chmod +x "${empty_dir}/bin/enable-auth.sh" +( + 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. +failbin="${test_dir}/fakebin" +mkdir -p "${failbin}" +real_cat="$(command -v cat)" +printf '%s\n' \ + '#!/bin/sh' \ + 'case "$*" in' \ + ' *.tmp) printf "auth.authenticator=par"; 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}" +( + 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. +[[ -e "${rb_file}.tmp" ]] +[[ -e "${rb_file}.bak" ]] +# Once the condition clears the same set goes through, and leaves nothing +# behind. +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}" +[[ ! -e "${rb_file}.tmp" ]] +[[ ! -e "${rb_file}.bak" ]] 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 8524894f26..8737d20088 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 @@ -55,22 +55,40 @@ fi # resolved in opposite directions, leaving Gremlin and REST on different # authenticators. +# 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 below silently did nothing. Neither the REST +# `auth.authenticator` nor the yaml `authentication:` block was written, +# while the entrypoint had already applied PASSWORD and init-store had run +# in auth mode — the servers then came up unauthenticated with no error. +# `sed -i '$a'` also closed the previous last line for us, which `>>` does +# not, so a file without a trailing newline gets one first. +append_lines() { + local file="$1" + shift + if [[ -s "${file}" && -n "$(tail -c 1 "${file}")" ]]; then + printf '\n' >> "${file}" + fi + printf '%s\n' "$@" >> "${file}" +} + AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" if ! grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then - sed -i -e '$a\authentication: {' \ - -e "\$a\\ authenticator: ${AUTHENTICATOR_CLASS}," \ - -e '$a\ authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ - -e '$a\ config: {tokens: conf/rest-server.properties}' \ - -e '$a\}' ${CONF}/${GREMLIN_SERVER_CONF} + append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \ + 'authentication: {' \ + " authenticator: ${AUTHENTICATOR_CLASS}," \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler,' \ + ' config: {tokens: conf/rest-server.properties}' \ + '}' fi if ! grep -Eq '^[[:blank:]]*auth[\\]?\.authenticator[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then - sed -i -e "\$a\\auth.authenticator=${AUTHENTICATOR_CLASS}" ${CONF}/${REST_SERVER_CONF} + append_lines "${CONF}/${REST_SERVER_CONF}" "auth.authenticator=${AUTHENTICATOR_CLASS}" fi if ! grep -Eq '^[[:blank:]]*auth[\\]?\.graph_store[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then - sed -i -e '$a\auth.graph_store=hugegraph' ${CONF}/${REST_SERVER_CONF} + append_lines "${CONF}/${REST_SERVER_CONF}" 'auth.graph_store=hugegraph' fi # GNU grep reads \r in a pattern as the letter r, so the carriage return a From bf8303294ba962224662a0b21da568329ef8459a Mon Sep 17 00:00:00 2001 From: Adarsh Mishra <122873385+Adarsh-Me@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:03:20 +0530 Subject: [PATCH 07/22] test(docker): cover the rollback branch where restoring also fails The injected `cat` can fail the copy-back and the restore at once, which is the only case where props.awk cannot repair the config. Assert the operator is pointed at the snapshot, that the snapshot is a byte-for-byte copy of what was there before, and that both staging files are left behind. --- .../docker/test/test-docker-entrypoint.sh | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 3b225d30d7..b89dc43d14 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -426,6 +426,7 @@ printf '%s\n' \ '#!/bin/sh' \ 'case "$*" in' \ ' *.tmp) printf "auth.authenticator=par"; exit 1 ;;' \ + ' *.bak) [ -n "${FAKE_BAK_FAIL:-}" ] && exit 1' \ 'esac' \ 'exec "${FAKE_CAT_REAL}" "$@"' \ > "${failbin}/cat" @@ -462,3 +463,29 @@ grep -q '^auth\.token_secret=s3cr3t$' "${rb_file}" grep -q '^unrelated=true$' "${rb_file}" [[ ! -e "${rb_file}.tmp" ]] [[ ! -e "${rb_file}.bak" ]] +# 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_out}" == *"${rb_file}.bak"* ]] || { + echo "props.awk must name the snapshot when the restore also fails" >&2 + exit 1 +} +# The damaged config keeps whatever the aborted copy left, and the +# snapshot still holds the last known good content. +[[ -e "${rb_file}.bak" ]] +[[ -e "${rb_file}.tmp" ]] +cmp -s "${rb_file}.bak" "${rb_expect}" || { + echo "the snapshot must be a byte-for-byte copy of the original" >&2 + exit 1 +} From b8801a650633639e0ee387171cdb4d328af8e0a1 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Wed, 23 Sep 2026 14:08:44 +0530 Subject: [PATCH 08/22] fix(docker): decide authentication by side, not by class Takes the review's simplification: the entrypoint no longer parses which authenticator gremlin-server.yaml names, so get_yaml_authenticator, the get-decoded mode in props.awk and the class comparison in align_auth_config all go away. What is kept is the guarantee those served - REST and Gremlin never end up with authentication on one side only - by refusing every one-sided layout instead of completing it. The refusal is kept honest by a three-state read of the yaml (none / named / nameless). Treating a mapping that names no authenticator as "no yaml side" would pass a REST-only config straight through to enable-auth.sh, whose guard only looks for the presence of the mapping and so would write the REST file alone: REST on StandardAuthenticator, Gremlin on TinkerPop's AllowAllAuthenticator default. That is the fail-open this PR exists to close. Also from the review: - the authentication key must start at column 0, so a mapping nested under an unrelated feature no longer decides the REST side. This drops the case the previous test asserted for an indented `authentication:`; a top-level key is what gremlin-server.yaml actually uses. - props_set refuses a value whose encoded form ends in an odd number of backslashes. Written where it is no longer the last line it swallows the line after it, and commons-configuration2 reads such a pair back as no property at all, so the entrypoint would publish a secret no server sees. - the post-startup backend read goes through props.awk, so a mounted `backend : hstore` no longer skips the partition-wait check silently. Net -56 lines of shell and awk, +105 of tests. Every new case was checked by reverting its fix: the backslash refusal, the column-0 requirement, the nameless refusal and the refusal path itself each turn the suite red on their own. Suite green here apart from the four assertions this host cannot execute. --- .../docker/docker-entrypoint.sh | 185 ++++------ .../hugegraph-dist/docker/props.awk | 27 +- .../docker/test/test-docker-entrypoint.sh | 319 ++++++++++++------ 3 files changed, 290 insertions(+), 241 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index fde8d253e0..1f238152e9 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -79,136 +79,77 @@ get_prop_encoded() { awk -f "${PROPS_AWK}" /dev/null } -# Decoded read: unescapes the on-disk value the way java.util.Properties -# does, so it compares equal with the snakeyaml-decoded scalar from -# get_yaml_authenticator. The raw get_prop_encoded mode stays for the -# secret round trip, which must replay backslashes byte-for-byte. -get_prop() { - local key="$1" file="$2" - - PROPS_MODE=get-decoded PROPS_KEY="${key}" PROPS_FILE="${file}" \ - awk -f "${PROPS_AWK}" /dev/null -} - -# First uncommented `authenticator:` inside the gremlin-server.yaml -# authentication block, or on the `authentication:` line itself (a flow -# mapping). snakeyaml resolves duplicate top-level keys to the last one, -# but a mounted file carrying two authentication blocks is pathological; -# report the first and let the mismatch WARN handle it. The scalar is -# cleaned the way snakeyaml reads it — an inline comment (a '#' preceded -# by whitespace), surrounding quotes and padding are stripped — because -# java.util.Properties keeps all of those in the class name. -get_yaml_authenticator() { +# What the top-level authentication mapping of gremlin-server.yaml says about +# authentication, as one of three states: +# +# none no such mapping +# named the mapping carries an authenticator +# nameless the mapping exists but names no authenticator +# +# Only presence is asked for, never the class: the entrypoint does not copy a +# value between the two files any more, so quotes, inline comments and flow +# mappings stay snakeyaml's business instead of becoming a parser here. The +# key must start at column 0 — an `authentication:` nested under another +# mapping belongs to that feature, not to the Gremlin server, and reading it as +# the Gremlin one would let an unrelated class decide whether REST is +# authenticated while Gremlin stayed on TinkerPop's AllowAllAuthenticator. +yaml_auth_state() { local yaml="./conf/gremlin-server.yaml" - [[ -f "${yaml}" ]] || return 0 + [[ -f "${yaml}" ]] || { echo "none"; return 0; } awk ' - function scalar(s, out, i, n, c, q) { - out = "" - q = "" - n = length(s) - for (i = 1; i <= n; i++) { - c = substr(s, i, 1) - if (q != "") { - if (c == q) q = "" - else out = out c - continue - } - if (c == "\"" || c == "\047") { q = c; continue } - if (c == "#" && - (out == "" || substr(out, length(out), 1) ~ /[ \t]/)) - break - if (c == "," || c == "}" || c == "]") break - out = out c - } - sub(/^[ \t\r]+/, "", out) - sub(/[ \t\r]+$/, "", out) - return out - } - /^[ \t]*#/ { next } - /^[ \t]*authentication[ \t]*:/ { + /^authentication[ \t]*:/ { inblk = 1 - indent = match($0, /[^ \t]/) - line = $0 - sub(/^[ \t]*authentication[ \t]*:[ \t]*/, "", line) - if (match(line, /authenticator[ \t]*:/)) { - print scalar(substr(line, RSTART + RLENGTH)) - exit - } + have = 1 + # A flow mapping keeps the authenticator on the same line as the + # key, so it has to count there too; missing it would report a + # configured mapping as nameless and refuse a valid deployment. + if (match($0, /authenticator[ \t]*:/)) { named = 1; exit } next } - # A blank line does not close a YAML mapping. - /^[ \t\r]*$/ { next } - # The authenticator has to belong to the authentication mapping: - # any key at or left of that key is a sibling, so the block is - # over. Without this, the first `authenticator:` anywhere below - # `authentication:` is taken as the Gremlin one, which lets a - # later top-level mapping carrying its own authenticator decide - # the REST side too. - inblk && match($0, /[^ \t]/) <= indent { inblk = 0 } - inblk && /^[ \t]+authenticator[ \t]*:/ { - line = $0 - sub(/^[ \t]*authenticator[ \t]*:[ \t]*/, "", line) - print scalar(line) - exit + # Any other column-0 key ends the mapping. A blank or whitespace-only + # line does not, because YAML does not close a mapping on an empty line. + inblk && /^[^ \t]/ { inblk = 0 } + inblk && /^[ \t]+authenticator[ \t]*:/ { named = 1; exit } + END { + if (named) print "named" + else if (have) print "nameless" + else print "none" } ' "${yaml}" } -# A mounted yaml can carry an authentication block whose authenticator -# cannot be read (an empty or unparseable one). That is not the -# both-empty case: exporting the default would override an explicit -# choice that snakeyaml does resolve, so callers treat it as a mismatch. -has_yaml_authentication_block() { - local yaml="./conf/gremlin-server.yaml" - - [[ -f "${yaml}" ]] || return 1 - grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${yaml}" -} - -# enable-auth.sh appends definitions to files it did not write. On a -# mounted config those appended definitions are duplicates the two parsers -# resolve in opposite directions — HugeConfig (commons-configuration) takes -# the first, snakeyaml takes the last — so Gremlin and REST can land on -# different authenticators with no error from either. Normalize both sides -# to one definition of the same authenticator here; enable-auth.sh's -# per-file guards then make its appends no-ops on anything already set. -align_auth_config() { - local rest_auth yaml_auth - - rest_auth=$(get_prop "auth.authenticator" "${REST_SERVER_CONF}") - yaml_auth=$(get_yaml_authenticator) - if [[ -z "${yaml_auth}" ]] && has_yaml_authentication_block; then - # Refuse instead of bootstrapping one side: enable-auth.sh runs right - # after align and only touches the REST side, so continuing would put - # REST on StandardAuthenticator while Gremlin stays on TinkerPop's - # AllowAllAuthenticator default. Failing fast (rather than skipping - # enable-auth.sh) keeps a PASSWORD deployment from starting with - # authentication silently half-applied. - log "ERROR: gremlin-server.yaml carries an authentication block" \ - "without a readable authenticator; refusing to bootstrap" \ - "authentication one-sided. Add an 'authenticator:' entry to" \ - "the block or remove the block, then restart." +# 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 + + 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 - if [[ -n "${rest_auth}" && -n "${yaml_auth}" && "${rest_auth}" != "${yaml_auth}" ]]; then - log "WARN: REST and Gremlin name different authenticators" \ - "('${rest_auth}' vs '${yaml_auth}'); leaving both untouched" - return + if [[ -n "$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}")" ]]; then + rest=1 fi - if [[ -z "${rest_auth}" && -z "${yaml_auth}" ]]; then - # Only fill in a default: an operator-supplied AUTHENTICATOR_CLASS - # is the intent for a config that names no authenticator yet, and - # assigning here would turn it back into StandardAuthenticator - # before enable-auth.sh ever saw it. - export AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" - elif [[ -n "${yaml_auth}" ]]; then - set_prop "auth.authenticator" "${yaml_auth}" "${REST_SERVER_CONF}" - else - export AUTHENTICATOR_CLASS="${rest_auth}" + if [[ "${state}" == "named" ]]; then + yaml=1 + fi + if (( rest == yaml )); then + return 0 fi - # auth.graph_store and the gremlin.graph flip are left to enable-auth.sh, - # which appends/rewrites only what is absent or still the plain default. + 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() { @@ -280,9 +221,9 @@ elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then fi if [[ -n "${PASSWORD:-}" ]]; then set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" - # A refusal inside align_auth_config exits the entrypoint under set -e, - # so enable-auth.sh can never run one-sided after it. - align_auth_config + # A refusal here exits the entrypoint under set -e, so enable-auth.sh can + # never run one-sided after it. + check_auth_sides # This script is idempotent and must run outside the initialization guard: # an upgrade can preserve the marker from an unauthenticated deployment. ./bin/enable-auth.sh @@ -356,7 +297,11 @@ fi ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t 120 # 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 `=`. Trailing whitespace is dropped +# here rather than in the reader, which reports the on-disk bytes verbatim. +ACTUAL_BACKEND=$(get_prop_encoded "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/props.awk b/hugegraph-server/hugegraph-dist/docker/props.awk index 1680f368c7..1dfb7ab8ae 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/docker/props.awk @@ -196,8 +196,19 @@ function props_load(file, raw, rc, nl, stripped, next_raw, start, logical) { } } -function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg) { +function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) { props_load(file) + # A value whose encoded form ends in an odd number of backslashes would + # turn the line written after it into a continuation of that value. + # Measured against commons-configuration2 (what HugeConfig extends), the + # same input read back yields no property at all, so a secret written this + # way would never reach the server that is supposed to authenticate with + # it; the entrypoint has to refuse instead of guessing a target. + nbs = 0 + while (nbs < length(enc_val) && substr(enc_val, length(enc_val) - nbs, 1) == "\\") + nbs++ + if (nbs % 2 == 1) + die("refusing to write " key ": the encoded value ends in an odd number of backslashes") first = 0 for (b = 1; b <= NBLOCK; b++) { if (BTYPE[b] == "entry" && BKEY[b] == key) { @@ -272,16 +283,6 @@ function props_get(file, key, b) { } } -function props_get_decoded(file, key, b) { - props_load(file) - for (b = 1; b <= NBLOCK; b++) { - if (BTYPE[b] == "entry" && BKEY[b] == key) { - print unescape(BVAL[b]) - return - } - } -} - BEGIN { mode = ENVIRON["PROPS_MODE"] key = ENVIRON["PROPS_KEY"] @@ -290,11 +291,9 @@ BEGIN { die("PROPS_FILE and PROPS_KEY must be set") if (mode == "get") { props_get(file, key) - } else if (mode == "get-decoded") { - props_get_decoded(file, key) } else if (mode == "set") { props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) } else { - die("PROPS_MODE must be get, get-decoded or set") + die("PROPS_MODE must be get or set") } } 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 b89dc43d14..536facfc47 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -27,8 +27,8 @@ trap 'rm -rf "${test_dir}"' EXIT # 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 \ - get_yaml_authenticator has_yaml_authentication_block align_auth_config; do +for fn in encode_prop_value set_prop_encoded set_prop get_prop_encoded \ + yaml_auth_state check_auth_sides; do eval "$(awk -v fn="${fn}" ' index($0, fn "() {") == 1 { capture = 1 } capture { print } @@ -138,56 +138,137 @@ 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}" -# get_yaml_authenticator must agree with snakeyaml on what a mounted -# gremlin-server.yaml says: the authenticator inside the authentication -# block — quoted scalars and inline comments cleaned the way snakeyaml -# strips them — and a flow mapping on the authentication line itself. -# align_auth_config refuses an authentication block without a readable -# authenticator instead of treating it as "no yaml side": exporting the -# default there would override an explicit choice, and continuing would let -# enable-auth.sh write the REST side alone. +# 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 - REST_SERVER_CONF="./conf/rest-server.properties" - : > "${REST_SERVER_CONF}" + 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' \ - > conf/gremlin-server.yaml - [[ "$(get_yaml_authenticator)" == "com.example.MyAuth" ]] + > "${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}}' \ - > conf/gremlin-server.yaml - [[ "$(get_yaml_authenticator)" == "com.example.FlowAuth" ]] + > "${state_file}" + want_state named "$(yaml_auth_state)" -# align_auth_config must refuse an authentication block without a readable -# authenticator: continuing would let enable-auth.sh write the REST side -# alone (REST on StandardAuthenticator, Gremlin on TinkerPop's -# AllowAllAuthenticator default), so the entrypoint stops here instead. printf '%s\n' \ 'authentication:' \ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ - > conf/gremlin-server.yaml - unset AUTHENTICATOR_CLASS - if align_auth_config; then - echo "align_auth_config must refuse an authentication block" \ - "without a readable authenticator" >&2 - exit 1 - fi - [[ -z "${AUTHENTICATOR_CLASS:-}" ]] - [[ ! -s "${REST_SERVER_CONF}" ]] + > "${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:' \ - ' authenticator: com.example.YamlAuth' \ + ' tokens: conf/rest-server.properties' \ + '' \ + ' authenticator: com.example.Later' \ + > "${state_file}" + want_state named "$(yaml_auth_state)" + + 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 - align_auth_config - grep -q '^auth\.authenticator=com\.example\.YamlAuth$' "${REST_SERVER_CONF}" + 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 refusal above is what keeps enable-auth.sh from writing one side: @@ -195,7 +276,7 @@ mkdir -p "${yaml_dir}/conf" # the REST file (its yaml guard already sees an `authentication:` line), # leaving REST on StandardAuthenticator and Gremlin on TinkerPop's # AllowAllAuthenticator default. The entrypoint never lets it run there -# because align_auth_config fails first under set -e. +# because check_auth_sides fails first under set -e. onesided_dir="${test_dir}/yaml-onesided" mkdir -p "${onesided_dir}/bin" "${onesided_dir}/conf/graphs" cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ @@ -212,9 +293,8 @@ chmod +x "${onesided_dir}/bin/enable-auth.sh" 'authentication:' \ ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ > conf/gremlin-server.yaml - unset AUTHENTICATOR_CLASS - if align_auth_config; then - echo "align_auth_config must refuse an authentication block without a readable authenticator" >&2 + if check_auth_sides; then + echo "check_auth_sides must refuse a yaml mapping without an authenticator" >&2 exit 1 fi ./bin/enable-auth.sh @@ -238,8 +318,6 @@ 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" ]] -[[ "$(get_prop 'auth.authenticator' "${crlf_file}")" == \ - "org.apache.hugegraph.auth.StandardAuthenticator" ]] 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" ]] @@ -248,27 +326,26 @@ if ! grep -q $'^unrelated=true\r$' "${crlf_file}"; then exit 1 fi -# An escaped authenticator and a plain yaml scalar name the same class: -# the comparison unescapes first, so no spurious WARN and no skipped -# alignment. -escaped_auth_dir="${test_dir}/yaml-escaped-auth" +# 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=org.apache.hugegraph.auth\.StandardAuthenticator' \ + 'auth\.authenticator=com.example.OldAuth' \ + 'unrelated=true' \ > "${REST_SERVER_CONF}" - printf '%s\n' \ - 'authentication:' \ - ' authenticator: org.apache.hugegraph.auth.StandardAuthenticator' \ - > conf/gremlin-server.yaml - unset AUTHENTICATOR_CLASS - align_out=$(align_auth_config 2>&1) - [[ -z "${AUTHENTICATOR_CLASS:-}" ]] - [[ "${align_out}" != *"different authenticators"* ]] - grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ - "${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 @@ -293,27 +370,20 @@ set_prop "init_store.enabled" "true" "${link_file}" [[ -L "${link_file}" ]] grep -q '^init_store\.enabled=true$' "${target_file}" -# An `authenticator:` below a *sibling* mapping is not the Gremlin one. -# `get_yaml_authenticator` opens its block on `authentication:` and has to -# close it again on the next key at the same indentation, or the yaml below -# reports com.example.TlsOnly — and align_auth_config then writes that -# class into rest-server.properties, so REST authenticates with a class the -# operator only ever mentioned to an unrelated mapping. +# 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:' \ - ' config: {tokens: conf/rest-server.properties}' \ - 'ssl:' \ - ' authenticator: com.example.TlsOnly' \ - > conf/gremlin-server.yaml - [[ -z "$(get_yaml_authenticator)" ]] - - # The block's own authenticator is still found when a sibling follows - # it, and one deeper than the key is still inside it. printf '%s\n' \ 'authentication:' \ ' authenticator: com.example.GremlinAuth' \ @@ -321,53 +391,58 @@ mkdir -p "${scope_dir}/conf" 'ssl:' \ ' authenticator: com.example.TlsOnly' \ > conf/gremlin-server.yaml - [[ "$(get_yaml_authenticator)" == "com.example.GremlinAuth" ]] + want_state named "$(yaml_auth_state)" - # A blank line does not close a YAML mapping, and neither does a - # comment — including one that names an authenticator. printf '%s\n' \ 'authentication:' \ - '' \ '# authenticator: com.example.CommentedAuth' \ - ' authenticator: com.example.BlankLineAuth' \ - > conf/gremlin-server.yaml - [[ "$(get_yaml_authenticator)" == "com.example.BlankLineAuth" ]] - - # Same indentation as the key means a sibling, not a member: the last - # case a mounted file is likely to get wrong, because a two-space - # `authentication:` under a top-level key is how some deployments - # indent the whole block. - printf '%s\n' \ - ' authentication:' \ - ' authenticator: com.example.IndentedAuth' \ - ' ssl:' \ - ' authenticator: com.example.TlsOnly' \ + ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ > conf/gremlin-server.yaml - [[ "$(get_yaml_authenticator)" == "com.example.IndentedAuth" ]] + want_state nameless "$(yaml_auth_state)" ) -# Both sides silent means "bootstrap authentication", but an operator who -# passed AUTHENTICATOR_CLASS named the class they want. The default may -# fill that in, it may not overwrite it: enable-auth.sh appends the value -# it is given, so overwriting here put StandardAuthenticator into a -# deployment that asked for something else. +# 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" -mkdir -p "${class_dir}/conf" ( - cd "${class_dir}" || exit 1 - REST_SERVER_CONF="./conf/rest-server.properties" - : > "${REST_SERVER_CONF}" - printf '%s\n' 'restserver.url=http://0.0.0.0:8080' > conf/gremlin-server.yaml - - AUTHENTICATOR_CLASS=com.example.OperatorAuth - export AUTHENTICATOR_CLASS - align_auth_config - [[ "${AUTHENTICATOR_CLASS}" == "com.example.OperatorAuth" ]] - - unset AUTHENTICATOR_CLASS - align_auth_config - [[ "${AUTHENTICATOR_CLASS}" == \ - "org.apache.hugegraph.auth.StandardAuthenticator" ]] + # 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}/bin" "${dir}/conf/graphs" + cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ + "${dir}/bin/enable-auth.sh" + chmod +x "${dir}/bin/enable-auth.sh" + 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" ) # An empty mounted config still gets its definitions. GNU sed's `$` @@ -489,3 +564,33 @@ cmp -s "${rb_file}.bak" "${rb_expect}" || { echo "the snapshot must be a byte-for-byte copy of the original" >&2 exit 1 } + +# 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}" From 5afbb4a3357d41798098ef9ec007d5dba6028b9c Mon Sep 17 00:00:00 2001 From: Adarsh Date: Wed, 23 Sep 2026 21:40:39 +0530 Subject: [PATCH 09/22] fix(docker): read properties and yaml the way the server does Addresses the blocking review. All eight findings reproduced first, and every fix below was reverted to confirm its own test goes red. props.awk, against java.util.Properties: - Line terminators. A bare CR ends a line in Java but not to getline, so a CR-only config reached the parser as one record: only its first key was ever seen, and rewriting that key replaced the whole record and dropped every later entry. Measured before: a file of three properties, one of them auth.authenticator; after set graph=..., one property left. Records are now split on \r\n, \n and \r, and the terminator each line arrived with is replayed so untouched lines keep their bytes. - Form feed. Java treats \f as whitespace either side of the separator, so `auth.authenticator=...` is that property; it was parsed into the key name instead, the guards read the file as unconfigured, and the append added a second competing definition. - Two read modes the guards needed: PROPS_MODE=has, which answers "is this key defined" without confusing an empty definition with no definition, and PROPS_DECODED=1 for callers that compare a value. Exit status 2 means an error and 1 means absent, so a caller wearing errexit cannot read an unreadable file as "not there" and append over it. gremlin-server.yaml: - yaml_auth_state moves to yamlscan.awk and answers about the mapping rather than the text. It reported named for `authentication: {} # authenticator: X`, and for a class nested under config:, both of which pass the parity check while Gremlin runs on AllowAllAuthenticator; and it reported nameless for a valid mapping with a comment line inside it, refusing a deployment that should start. Only a direct child counts, in block and flow form alike, comment text is not content, and an authenticator with no class is the nameless case. A separate file, which is also what keeps it free of the apostrophe that breaks a shell-quoted awk program. - check_auth_sides now runs on every start. Inside the PASSWORD branch only, a mounted REST-side authenticator with no yaml mapping was never validated. - enable-auth.sh wrapped the graph factory on a grep that matched a literal or backslash-escaped dot, so the legal `gremlin\u002egraph` spelling left the factory unwrapped with both servers told authentication was on. That read/write now goes through props.awk, and the embedded-CR workaround the grep needed goes with it. The script also stops on the first failed append: it exited 0 while a read-only mounted yaml left REST configured and the yaml not. props.awk moves to the assembly bin/ it is packaged from, which is where bin/enable-auth.sh finds it in the tarball as well as the image; the Dockerfile COPY of it is gone, since the image takes bin/ from the assembly. Verified: props.awk against java.util.Properties (javac/java 17) over a corpus of terminator, separator and escape forms, on read and on rewrite, 0 disagreements; yamlscan.awk over 21 shapes; both entrypoint suites; and the eight findings as a table, 8/8 failing at b8801a6 and 8/8 passing now. Not run here: mawk (no Linux container on this host), snakeyaml, and the CR-byte, chmod-mode and symlink assertions, which need a host where those primitives behave; they are gated to say so rather than pass quietly, and they run in CI. --- hugegraph-server/Dockerfile | 4 +- hugegraph-server/Dockerfile-hstore | 4 +- .../docker/docker-entrypoint-test.sh | 51 ++- .../docker/docker-entrypoint.sh | 95 ++-- .../docker/test/test-docker-entrypoint.sh | 425 +++++++++++++++++- .../hugegraph-dist/docker/yamlscan.awk | 257 +++++++++++ .../src/assembly/static/bin/enable-auth.sh | 131 ++++-- .../assembly/static/bin}/props.awk | 133 ++++-- 8 files changed, 969 insertions(+), 131 deletions(-) create mode 100644 hugegraph-server/hugegraph-dist/docker/yamlscan.awk rename hugegraph-server/hugegraph-dist/{docker => src/assembly/static/bin}/props.awk (69%) diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile index f360adcb68..a2c059db5c 100644 --- a/hugegraph-server/Dockerfile +++ b/hugegraph-server/Dockerfile @@ -66,7 +66,9 @@ RUN apt-get -q update \ 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 . -COPY hugegraph-server/hugegraph-dist/docker/props.awk . +# 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 81f1063d90..b7977b1724 100644 --- a/hugegraph-server/Dockerfile-hstore +++ b/hugegraph-server/Dockerfile-hstore @@ -68,7 +68,9 @@ RUN apt-get -q update \ 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 . -COPY hugegraph-server/hugegraph-dist/docker/props.awk . +# 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 6250ab4f14..47f11e55ae 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -23,7 +23,10 @@ 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" -cp "${SCRIPT_DIR}/props.awk" "${TEST_HOME}/props.awk" +# 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' @@ -233,4 +236,50 @@ rm -f "${TEST_HOME}/docker/init_complete" ) grep -Fqx -- '-n' "${TEST_HOME}/docker/init-store-password" +# 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 +) + 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 1f238152e9..5347b66c69 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -28,13 +28,40 @@ log() { echo "[hugegraph-server-entrypoint] $*"; } # Property reading/writing goes through props.awk, which implements the # java.util.Properties grammar HugeConfig applies (escapes, `:`/whitespace -# separators, 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="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/props.awk" -if [[ ! -f "${PROPS_AWK}" ]]; then - log "ERROR: props.awk not found next to the entrypoint" +# 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 @@ -80,43 +107,22 @@ get_prop_encoded() { } # What the top-level authentication mapping of gremlin-server.yaml says about -# authentication, as one of three states: +# authentication, as one of three states: none, named, nameless. # -# none no such mapping -# named the mapping carries an authenticator -# nameless the mapping exists but names no authenticator -# -# Only presence is asked for, never the class: the entrypoint does not copy a -# value between the two files any more, so quotes, inline comments and flow -# mappings stay snakeyaml's business instead of becoming a parser here. The -# key must start at column 0 — an `authentication:` nested under another -# mapping belongs to that feature, not to the Gremlin server, and reading it as -# the Gremlin one would let an unrelated class decide whether REST is -# authenticated while Gremlin stayed on TinkerPop's AllowAllAuthenticator. +# 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 ' - /^authentication[ \t]*:/ { - inblk = 1 - have = 1 - # A flow mapping keeps the authenticator on the same line as the - # key, so it has to count there too; missing it would report a - # configured mapping as nameless and refuse a valid deployment. - if (match($0, /authenticator[ \t]*:/)) { named = 1; exit } - next - } - # Any other column-0 key ends the mapping. A blank or whitespace-only - # line does not, because YAML does not close a mapping on an empty line. - inblk && /^[^ \t]/ { inblk = 0 } - inblk && /^[ \t]+authenticator[ \t]*:/ { named = 1; exit } - END { - if (named) print "named" - else if (have) print "nameless" - else print "none" - } - ' "${yaml}" + awk -f "${YAMLSCAN}" "${yaml}" } # Authentication has to be configured on both sides or on neither. A mounted @@ -219,11 +225,16 @@ 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}" - # A refusal here exits the entrypoint under set -e, so enable-auth.sh can - # never run one-sided after it. - check_auth_sides # This script is idempotent and must run outside the initialization guard: # an upgrade can preserve the marker from an unauthenticated deployment. ./bin/enable-auth.sh 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 536facfc47..8c13198960 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -36,8 +36,54 @@ for fn in encode_prop_value set_prop_encoded set_prop get_prop_encoded \ ' "${entrypoint}")" done log() { echo "[hugegraph-server-entrypoint] $*"; } -PROPS_AWK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/props.awk" -export PROPS_AWK +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. +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" + 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}" + +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" @@ -279,9 +325,7 @@ mkdir -p "${sides_dir}/conf" # because check_auth_sides fails first under set -e. onesided_dir="${test_dir}/yaml-onesided" mkdir -p "${onesided_dir}/bin" "${onesided_dir}/conf/graphs" -cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ - "${onesided_dir}/bin/enable-auth.sh" -chmod +x "${onesided_dir}/bin/enable-auth.sh" +install_enable_auth "${onesided_dir}" ( cd "${onesided_dir}" || exit 1 REST_SERVER_CONF="./conf/rest-server.properties" @@ -313,7 +357,12 @@ chmod +x "${onesided_dir}/bin/enable-auth.sh" # 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}" -printf 'pd.peers=a,\\\r\n b\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" ]] @@ -321,9 +370,13 @@ printf 'unrelated=true\r\n' >> "${crlf_file}" 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 ! grep -q $'^unrelated=true\r$' "${crlf_file}"; then - echo "CRLF bytes of untouched lines must be preserved" >&2 - exit 1 +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 @@ -356,19 +409,33 @@ 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}" -[[ "$(stat -c '%a' "${mode_file}")" == "600" ]] 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" -printf '%s\n' 'unrelated=true' > "${target_file}" -ln -s "${target_file}" "${link_file}" -set_prop "init_store.enabled" "true" "${link_file}" -[[ -L "${link_file}" ]] -grep -q '^init_store\.enabled=true$' "${target_file}" +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 @@ -412,10 +479,8 @@ class_dir="${test_dir}/authenticator-class" # 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}/bin" "${dir}/conf/graphs" - cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ - "${dir}/bin/enable-auth.sh" - chmod +x "${dir}/bin/enable-auth.sh" + 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" @@ -453,9 +518,7 @@ class_dir="${test_dir}/authenticator-class" # 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" -cp "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../src/assembly/static/bin" && pwd)/enable-auth.sh" \ - "${empty_dir}/bin/enable-auth.sh" -chmod +x "${empty_dir}/bin/enable-auth.sh" +install_enable_auth "${empty_dir}" ( cd "${empty_dir}" || exit 1 : > conf/rest-server.properties @@ -594,3 +657,321 @@ 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}" + +# has-mode answers "is this key defined" without confusing an empty definition +# with no definition, which is what an append guard needs: appending a default +# on top of `auth.authenticator=` leaves the empty first definition in force. +has_file="${test_dir}/config-has" +printf 'auth.authenticator=\n' > "${has_file}" +if ! PROPS_MODE=has PROPS_KEY='auth.authenticator' PROPS_FILE="${has_file}" \ + awk -f "${PROPS_AWK}" /dev/null; then + echo "PROPS_MODE=has must report an empty definition as present" >&2 + exit 1 +fi +if PROPS_MODE=has PROPS_KEY='auth.graph_store' PROPS_FILE="${has_file}" \ + awk -f "${PROPS_AWK}" /dev/null; then + echo "PROPS_MODE=has must report an absent key as absent" >&2 + exit 1 +fi +# An unreadable file must not read as "absent": status 2 is what tells a caller +# wearing errexit to stop rather than append a default over a file it could not +# read. +status=0 +PROPS_MODE=has PROPS_KEY='k' PROPS_FILE="${test_dir}/no-such-file" \ + awk -f "${PROPS_AWK}" /dev/null 2>/dev/null || status=$? +if (( status != 2 )); then + echo "PROPS_MODE=has 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 +) + +# ── 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' + +# ── 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 +) diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk new file mode 100644 index 0000000000..4c127eb7c5 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -0,0 +1,257 @@ +# 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 start at column 0 -- an `authentication:` nested under +# some other key belongs to that feature, not to the Gremlin server; +# 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. +# +# 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() } + +# 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 +} + +# One layer of matching quotes off a key or scalar. +function unquote(s, f) { + s = trim(s) + if (length(s) >= 2) { + f = substr(s, 1, 1) + if ((f == apos() || f == dquo()) && substr(s, length(s), 1) == f) + return substr(s, 2, length(s) - 2) + } + 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. +function names_class(v) { + v = trim(v) + return v != "" && v != "null" && v != "~" +} + +# Report and stop. Output happens in END only, because awk runs END after +# `exit` and a second print there would emit two states on one run. +function finish(r) { RESULT = r; exit } + +# 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. FSET records a direct +# authenticator that names a class. Returns 1 once the outermost collection +# has closed. +function scan_flow(s, i, n, c, q) { + n = length(s) + q = "" + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (q != "") { + if (FST == "key") CUR = CUR c + if (c == q) q = "" + continue + } + if (is_quote(c)) { + q = c + if (FST == "key") CUR = CUR 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 (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) && names_class(CUR_VAL)) FSET = 1 +} + +BEGIN { + DEPTH = 0 + FST = "key" + CUR = "" + CUR_KEY = "" + CUR_VAL = "" + FSET = 0 + found = 0 + child = -1 + flow = 0 + RESULT = "" +} + +{ + line = strip_comment($0) + + if (!found) { + if (line ~ /^[ \t]/) next + if (!split_pair(line)) next + if (unquote(K_TXT) != "authentication") next + found = 1 + if (substr(V_TXT, 1, 1) == "{") { + flow = 1 + if (scan_flow(V_TXT)) finish(FSET ? "named" : "nameless") + next + } + # 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. + next + } + + if (flow) { + if (scan_flow(line)) finish(FSET ? "named" : "nameless") + next + } + + if (trim(line) == "") next + # A column-0 line after the comment was stripped is a sibling key, so the + # mapping has ended. + if (indent_of(line) == 0) finish("nameless") + + if (!split_pair(line)) next + if (child < 0) child = indent_of(line) + if (indent_of(line) != child) next + if (names_authenticator(K_TXT) && names_class(V_TXT)) finish("named") +} + +END { + if (RESULT != "") { print RESULT; exit } + if (!found) print "none" + else print "nameless" +} 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 8737d20088..639003f702 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,42 +36,102 @@ 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; the entrypoint also exports +# PROPS_AWK when it calls this script. +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" + +# Exit status of the reader is meaningful: 1 means the key has no definition, +# 2 means props.awk could not do its job. Only 1 is an acceptable answer here. +props_has() { + local status=0 + PROPS_MODE=has PROPS_KEY="$1" PROPS_FILE="$2" awk -f "${PROPS_AWK}" /dev/null || status=$? + if (( status > 1 )); then + fail "cannot read $2" + fi + return "${status}" +} + +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}" +} + +props_set() { + # The only values written here are Java class names, whose characters need + # no properties escaping; anything else would have to go through the + # entrypoint's encoder first. + case "$2" in + *[!A-Za-z0-9_\.\$]*) fail "refusing to write an unescaped value: $2" ;; + esac + PROPS_MODE=set PROPS_KEY="$1" PROPS_VALUE_ENCODED="$2" PROPS_FILE="$3" \ + awk -f "${PROPS_AWK}" /dev/null || fail "cannot update $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 -# The appends below are guarded per file and match only an absent or still -# commented-out definition, so they are no-ops on any config that already -# carries authentication (e.g. a mounted one, or a re-run of this script). -# The guards accept every spelling java.util.Properties reads as the key — -# '=' or ':' or bare-whitespace separators, leading whitespace and -# backslash-escaped dots — and the gremlin.graph flip tolerates CRLF -# endings, which a mounted config saved on Windows carries. 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. - -# 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 below silently did nothing. Neither the REST -# `auth.authenticator` nor the yaml `authentication:` block was written, -# while the entrypoint had already applied PASSWORD and init-store had run -# in auth mode — the servers then came up unauthenticated with no error. -# `sed -i '$a'` also closed the previous last line for us, which `>>` does -# not, so a file without a trailing newline gets one first. +# The appends below are guarded per file and skip any file that already carries +# the property, 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. +# +# 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}" + printf '\n' >> "${file}" || fail "cannot append to ${file}" fi - printf '%s\n' "$@" >> "${file}" + printf '%s\n' "$@" >> "${file}" || fail "cannot append to ${file}" } AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" @@ -83,19 +145,18 @@ if ! grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERV '}' fi -if ! grep -Eq '^[[:blank:]]*auth[\\]?\.authenticator[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then +if ! props_has "auth.authenticator" "${CONF}/${REST_SERVER_CONF}"; then append_lines "${CONF}/${REST_SERVER_CONF}" "auth.authenticator=${AUTHENTICATOR_CLASS}" fi -if ! grep -Eq '^[[:blank:]]*auth[\\]?\.graph_store[[:blank:]]*([:=]|[[:blank:]])' "${CONF}/${REST_SERVER_CONF}"; then +if ! props_has "auth.graph_store" "${CONF}/${REST_SERVER_CONF}"; then append_lines "${CONF}/${REST_SERVER_CONF}" 'auth.graph_store=hugegraph' fi -# GNU grep reads \r in a pattern as the letter r, so the carriage return a -# CRLF line ends with is embedded as a byte: without it the anchored guard -# misses a mounted CRLF config and the factory is never wrapped for auth -# although both servers already believe authentication is on. -CR=$'\r' -if grep -Eq "^[[:blank:]]*gremlin[\\\\]?\\.graph[[:blank:]]*([:=]|[[:blank:]])[[:blank:]]*org\\.apache\\.hugegraph\\.HugeFactory[[:blank:]]*${CR}?$" "${CONF}/graphs/${GRAPH_CONF}"; then - sed -i -E "s#^([[:blank:]]*gremlin[\\\\]?\\.graph[[:blank:]]*([:=]|[[:blank:]])[[:blank:]]*)org\\.apache\\.hugegraph\\.HugeFactory#\\1org.apache.hugegraph.auth.HugeFactoryAuthProxy#" "${CONF}/graphs/${GRAPH_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. +GRAPH_FACTORY=$(props_get "gremlin.graph" "${CONF}/graphs/${GRAPH_CONF}") +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/docker/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk similarity index 69% rename from hugegraph-server/hugegraph-dist/docker/props.awk rename to hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk index 1dfb7ab8ae..244202b821 100644 --- a/hugegraph-server/hugegraph-dist/docker/props.awk +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -23,7 +23,12 @@ # 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 +# 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. Always exits 0. +# PROPS_MODE=has PROPS_KEY=K PROPS_FILE=F +# print nothing; exit 0 when K has any definition at all, empty +# included, 1 when it has none, 2 on an error # 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 @@ -34,9 +39,11 @@ # # 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, with whitespace then an optional -# single '=' or ':' accepted as one separator +# - '=' / ':' / 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 @@ -48,7 +55,10 @@ function die(msg) { printf "props.awk: %s\n", msg > "/dev/stderr" - exit 1 + # 2 for an error, so a caller that reads exit status 1 as "the key is not + # there" (PROPS_MODE=has) cannot mistake an unreadable file for an absent + # property and append a definition on top of one it failed to read. + exit 2 } function hex_digit(c) { @@ -101,11 +111,14 @@ function trailing_backslashes(s, n, k) { } function is_skipped(raw) { - return raw ~ /^[ \t]*([#!]|$)/ + 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 @@ -114,7 +127,7 @@ function split_kv(s, n, i, c, esc, sep_at, rest) { c = substr(s, i, 1) if (esc) { esc = 0; continue } if (c == "\\") { esc = 1; continue } - if (c == "=" || c == ":" || c == " " || c == "\t") { sep_at = i; break } + if (c == "=" || c == ":" || c == " " || c == "\t" || c == "\f") { sep_at = i; break } } if (sep_at == 0) { K_RAW = s @@ -127,11 +140,11 @@ function split_kv(s, n, i, c, esc, sep_at, rest) { if (c == "=" || c == ":") { rest = substr(rest, 2) } else { - sub(/^[ \t]+/, "", rest) + sub(/^[ \t\f]+/, "", rest) c = substr(rest, 1, 1) if (c == "=" || c == ":") rest = substr(rest, 2) } - sub(/^[ \t]+/, "", rest) + sub(/^[ \t\f]+/, "", rest) V_RAW = rest } @@ -140,26 +153,63 @@ function shquote(s) { return "'" s "'" } +# 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, nl, stripped, next_raw, start, logical) { - NLINES = 0 - while ((rc = (getline raw < file)) > 0) { - NLINES++ - RAW[NLINES] = raw - } +function props_load(file, raw, rc, content, nl, stripped, next_raw, start, logical) { + 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 = RAW[nl] - # CRLF: java.util.Properties drops the line terminator, so one - # trailing CR is stripped for parsing only. RAW[] keeps the byte - # so props_set replays untouched lines byte-for-byte. - stripped = raw - sub(/\r$/, "", stripped) + # 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" @@ -173,15 +223,14 @@ function props_load(file, raw, rc, nl, stripped, next_raw, start, logical) { logical = substr(logical, 1, length(logical) - 1) nl++ next_raw = RAW[nl] - sub(/\r$/, "", next_raw) - sub(/^[ \t]+/, "", next_raw) + 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]+/, "", logical) + sub(/^[ \t\f]+/, "", logical) split_kv(logical) NBLOCK++ BTYPE[NBLOCK] = "entry" @@ -231,8 +280,14 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) if (b == first) { printf "%s=%s\n", key, enc_val > tmp } else { - for (ln = BFIRST[b]; ln <= BLAST[b]; ln++) - print RAW[ln] > tmp + 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) @@ -273,27 +328,47 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) die("cannot remove " tmp " and " bak " after the copy-back") } -function props_get(file, key, b) { +function props_get(file, key, decoded, b) { props_load(file) for (b = 1; b <= NBLOCK; b++) { if (BTYPE[b] == "entry" && BKEY[b] == key) { - print BVAL[b] + 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. PROPS_MODE=has is the mode that reports by status. +} + +# Exit status only: 0 when the key has any definition at all, including an +# empty one. Guards that append a default must not treat `auth.authenticator=` +# as absent, because appending a second definition leaves the empty first one +# in force under first-definition-wins. +function props_has(file, key, b) { + props_load(file) + for (b = 1; b <= NBLOCK; b++) { + if (BTYPE[b] == "entry" && BKEY[b] == key) return 0 + } + return 1 } 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) + props_get(file, key, decoded) + } else if (mode == "has") { + if (props_has(file, key)) exit 1 } else if (mode == "set") { props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) } else { - die("PROPS_MODE must be get or set") + die("PROPS_MODE must be get, has or set") } } From 2c9ebaf0bcbbad44e1008b312390e0a7a74e0ba4 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Wed, 23 Sep 2026 23:00:18 +0530 Subject: [PATCH 10/22] fix(docker): let enable-auth.sh answer what the entrypoint asks check_auth_sides and enable-auth.sh decide one question from two files, and the guards here asked it about presence while the entrypoint asks it about the value. Both findings were reproduced against this script before anything changed, and both new test groups were run against the unpatched script to confirm they go red. - Column-0 `authentication` only. The yaml guard accepted the key at any indentation while yamlscan.awk counts only a column-0 mapping, so a config that nests `authentication` under another feature read as `none`: parity held, the entrypoint ran this script, and the guard then saw the nested key, skipped the append and wrote the REST side alone. Gremlin stayed on TinkerPop's AllowAllAuthenticator under a StandardAuthenticator REST. The nested block is still left byte-identical, and that is asserted too. - A defined-but-empty authenticator is the unconfigured side. props_has reports `auth.authenticator=`, a bare `auth.authenticator` line and `auth.authenticator= ` as present, and they are: measured against java.util.Properties all three parse to the empty string, and HugeAuthenticator.loadAuthenticator returns null for that, so REST serves without authentication. The presence guard skipped the append on exactly the side that needed it. The two REST defaults now go through props_set, which rewrites an existing definition where it stands -- the placeholder is replaced rather than followed by a second definition that first-definition-wins would bury -- and appends when there is nothing to replace. An operator-written class is still never overwritten. - props_has had no caller left once both defaults moved, so it is gone. PROPS_MODE=has stays in props.awk: it is a documented answer to a different question and is still covered directly. Not verified here: CR-only and CRLF gremlin-server.yaml, which MSYS text mode cannot observe, and mawk, which is not installed on this host. Both are exercised by CI on Ubuntu. --- .../docker/test/test-docker-entrypoint.sh | 131 ++++++++++++++++++ .../src/assembly/static/bin/enable-auth.sh | 63 +++++---- 2 files changed, 170 insertions(+), 24 deletions(-) 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 8c13198960..411c711a6e 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -975,3 +975,134 @@ mkdir -p "${mounted_dir}/conf/graphs" > 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 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 639003f702..f64cec5dca 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 @@ -62,17 +62,9 @@ for candidate in "${PROPS_AWK:-}" "${BIN}/props.awk" "${TOP}/props.awk"; do done [[ -n "${PROPS_AWK:-}" ]] || fail "props.awk not found beside this script" -# Exit status of the reader is meaningful: 1 means the key has no definition, -# 2 means props.awk could not do its job. Only 1 is an acceptable answer here. -props_has() { - local status=0 - PROPS_MODE=has PROPS_KEY="$1" PROPS_FILE="$2" awk -f "${PROPS_AWK}" /dev/null || status=$? - if (( status > 1 )); then - fail "cannot read $2" - fi - return "${status}" -} - +# 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" \ @@ -94,6 +86,24 @@ props_set() { 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 with props_has 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 props_has 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. +ensure_rest_prop() { + [[ -n "$(props_get "$1" "$3")" ]] && return 0 + props_set "$1" "$2" "$3" +} + # make a backup BAK_CONF="$TOP/conf-bak" if [ ! -d "$BAK_CONF" ]; then @@ -106,11 +116,14 @@ if [ ! -d "$BAK_CONF" ]; then fail "cannot back up ${GRAPH_CONF}" fi -# The appends below are guarded per file and skip any file that already carries -# the property, 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. +# 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 @@ -136,7 +149,14 @@ append_lines() { AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" -if ! grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then +# 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 +# `[[:blank:]]*` here the two disagreed on a config that nests `authentication` +# under another feature: the entrypoint read it as `none`, so parity held and it +# called this script, but this guard saw the nested key and skipped the append, +# writing the REST side only -- StandardAuthenticator on REST, TinkerPop's +# AllowAllAuthenticator on Gremlin. +if ! grep -Eq '^authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \ 'authentication: {' \ " authenticator: ${AUTHENTICATOR_CLASS}," \ @@ -145,13 +165,8 @@ if ! grep -Eq '^[[:blank:]]*authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERV '}' fi -if ! props_has "auth.authenticator" "${CONF}/${REST_SERVER_CONF}"; then - append_lines "${CONF}/${REST_SERVER_CONF}" "auth.authenticator=${AUTHENTICATOR_CLASS}" -fi - -if ! props_has "auth.graph_store" "${CONF}/${REST_SERVER_CONF}"; then - append_lines "${CONF}/${REST_SERVER_CONF}" 'auth.graph_store=hugegraph' -fi +ensure_rest_prop "auth.authenticator" "${AUTHENTICATOR_CLASS}" "${CONF}/${REST_SERVER_CONF}" +ensure_rest_prop "auth.graph_store" "hugegraph" "${CONF}/${REST_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. From d5ccb942f669391448eecf220ac47e4e86d780a2 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Thu, 24 Sep 2026 10:23:53 +0530 Subject: [PATCH 11/22] fix(docker): read the authenticator value the server actually reads Four findings from the re-review. Each was reproduced against this tree before anything changed, and each fix was then reverted to confirm its own test goes red -- 20 cases red before, 20 green after. yamlscan.awk - names_class() accepted any spelling of null except the lowercase one. snakeyaml resolves null case-insensitively, so `authenticator: NULL`, `Null` and `nUll` are the null node, and `!!null` states it outright; every one of them reported `named`, which is the one answer that cannot be forgiven: check_auth_sides then saw authentication on both sides and let REST enforce StandardAuthenticator over a Gremlin on AllowAllAuthenticator. An empty quoted scalar belongs here too -- the JVM hands loadAuthenticator the empty string and it returns null -- as does a value whose type comes from a tag this scanner cannot resolve, which is now refused rather than guessed at. A quoted "null" deliberately stays `named`: quoting makes it a string, and a class that does not exist fails loudly at startup instead of silently opening the server. Pinned as a case so a later tightening of the null rules has to move it on purpose. - scan_flow() lost the contents of a quoted value. Only keys accumulated inside the quotes, so {authenticator: "org.example.Auth"} arrived at commit_val() empty and read as nameless, refusing a valid mounted config before the server ever started. Quoted bytes now accumulate for a value as they do for a key, with the double-quoted backslash escape honoured so an inner quote does not end the scalar early. enable-auth.sh - The yaml half of the append guard now asks the same reader check_auth_sides uses. grep only ever matched 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 resolve the key in opposite directions while REST keeps the authenticator the operator named. Measured before the change: one top-level key in, two out, in both the image and the tarball layout. The image gets yamlscan.awk from the install home, so PROPS_AWK-style discovery covers it; the plain release tarball carries no copy, and there the fallback grep at least knows the quoted spellings. Anything other than `none` means the mapping is there and is not this script's to duplicate. docker-entrypoint.sh - ACTUAL_BACKEND is compared against a literal, so it has to be read decoded. Against java.util.Properties as the oracle, a mounted `backend=hstore` is hstore to the JVM while the on-disk bytes are not, and the comparison then skipped wait-partition.sh and let startup continue before partitions were assigned. get_prop_decoded() is the new reader and the end-to-end case asserts wait-partition.sh is reached for the escaped spelling and still not reached for rocksdb. The other get_prop_encoded() callers were measured rather than swept along: the presence test at the parity check does not need decoding, because the encoded reader already trims trailing blanks the way Properties does, so the two readers agree there and only this literal comparison was wrong. Verified: yamlscan.awk over the null spellings, quoted flow values and nested flow cases (10 red / 20 ok after); enable-auth.sh in both layouts; the escaped-backend read against java.util.Properties on jdk17; both CI-wired shell suites at exit 0 with output identical to the pre-change baseline, and docker-entrypoint-test.sh reaching its PASS line; the wait-partition case shown red by restoring get_prop_encoded at that one call site. Not verified here: mawk, which is not installed on this host; CR-only and CRLF gremlin-server.yaml, which MSYS text mode cannot observe; snakeyaml itself, never executed, so the null spellings follow the resolver's documented behaviour rather than a run of it, and the refusal of an unresolvable tag is a judgement call about the safe direction rather than a measurement; and no Docker daemon, so no image build. --- .../docker/docker-entrypoint-test.sh | 46 +++++++ .../docker/docker-entrypoint.sh | 21 ++- .../docker/test/test-docker-entrypoint.sh | 128 +++++++++++++++++- .../hugegraph-dist/docker/yamlscan.awk | 50 ++++++- .../src/assembly/static/bin/enable-auth.sh | 53 ++++++-- 5 files changed, 281 insertions(+), 17 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index 2eeffbad5f..3aceea1aff 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -58,6 +58,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' @@ -374,4 +375,49 @@ rm -f "${TEST_HOME}/docker/init_complete" 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 93d77d4fc2..dcb39c4f1f 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -106,6 +106,17 @@ get_prop_encoded() { 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. # @@ -333,9 +344,13 @@ fi # Post-startup cluster stabilization check (hstore only — rocksdb has no partitions) # 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 `=`. Trailing whitespace is dropped -# here rather than in the reader, which reports the on-disk bytes verbatim. -ACTUAL_BACKEND=$(get_prop_encoded "backend" "${GRAPH_CONF}" | tr -d '[:space:]' || true) +# 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 411c711a6e..63317ac7ee 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -28,7 +28,7 @@ trap 'rm -rf "${test_dir}"' EXIT # 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 \ - yaml_auth_state check_auth_sides; do + get_prop_decoded yaml_auth_state check_auth_sides; do eval "$(awk -v fn="${fn}" ' index($0, fn "() {") == 1 { capture = 1 } capture { print } @@ -45,11 +45,16 @@ 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" } @@ -937,6 +942,47 @@ yaml_case nameless "sibling key closes the mapping" \ ' 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' # ── Mounted one-sided config is refused with no PASSWORD ─────────────── # check_auth_sides used to run only inside the PASSWORD branch, so a mounted @@ -1106,3 +1152,83 @@ 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" ]] diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index 4c127eb7c5..9daf1b15c0 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -124,10 +124,37 @@ function split_pair(s, i, n, c, q) { function names_authenticator(k) { return unquote(k) == "authenticator" } -# An authenticator entry only counts when it actually names a class. -function names_class(v) { +# 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; +# - an unterminated quote is not a scalar at all. +function names_class(v, first, last, body) { v = trim(v) - return v != "" && v != "null" && v != "~" + if (v == "") return 0 + first = substr(v, 1, 1) + if (first == "!") return 0 + 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 } # Report and stop. Output happens in END only, because awk runs END after @@ -139,19 +166,32 @@ function finish(r) { RESULT = r; exit } # a nested mapping under `config` invisible to it. FSET records a direct # authenticator that names a class. Returns 1 once the outermost collection # has closed. -function scan_flow(s, i, n, c, q) { +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 - if (c == q) q = "" + 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 == "[") { 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 f64cec5dca..e090530296 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 @@ -52,8 +52,8 @@ fail() { # 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; the entrypoint also exports -# PROPS_AWK when it calls this script. +# 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}" @@ -62,6 +62,20 @@ for candidate in "${PROPS_AWK:-}" "${BIN}/props.awk" "${TOP}/props.awk"; do 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. @@ -149,14 +163,37 @@ append_lines() { AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" +# Does the Gremlin config already carry a top-level `authentication` mapping? +# 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. Anything other than `none` means a mapping is there and the +# append is not this script's to make. +gremlin_has_auth_block() { + local file="$1" state + [[ -f "${file}" ]] || return 1 + if [[ -n "${YAMLSCAN}" ]]; then + state=$(awk -f "${YAMLSCAN}" "${file}") || fail "cannot read ${file}" + [[ "${state}" != "none" ]] + return + 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. + grep -Eq "^[\"']?authentication[\"']?[[:blank:]]*:" "${file}" +} + # 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 -# `[[:blank:]]*` here the two disagreed on a config that nests `authentication` -# under another feature: the entrypoint read it as `none`, so parity held and it -# called this script, but this guard saw the nested key and skipped the append, -# writing the REST side only -- StandardAuthenticator on REST, TinkerPop's -# AllowAllAuthenticator on Gremlin. -if ! grep -Eq '^authentication[[:blank:]]*:' "${CONF}/${GREMLIN_SERVER_CONF}"; then +# 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_has_auth_block "${CONF}/${GREMLIN_SERVER_CONF}"; then append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \ 'authentication: {' \ " authenticator: ${AUTHENTICATOR_CLASS}," \ From cc9cc8c81ae3f366cbe04538b0208c312db4bb67 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Thu, 24 Sep 2026 20:23:01 +0530 Subject: [PATCH 12/22] fix(docker): answer the config questions the guards cannot resolve Eight findings from the re-review, all reproduced against this tree before anything was edited, and each fix then reverted to confirm its own test group goes red -- eight red groups against the previous commit, eight green with this one, and both CI-wired suites (docker/test/test-docker-entrypoint.sh, docker/docker-entrypoint-test.sh) exit 0 with output unchanged from baseline. yamlscan.awk - names_class() read an anchor label as the value. `authenticator: &noAuth null` is a valid document whose authenticator resolves to null, and an alias `*noAuth` names a node this scanner does not resolve; both answered `named`, which is the direction that lets REST enforce StandardAuthenticator over a Gremlin left on AllowAllAuthenticator. The label now comes off and the text behind it decides, an anchor with nothing behind it is empty, and an alias is refused. `&cls com.example.Anchored` still names its class, because refusing a mounted config before startup is its own bug. - A mapping was answered at the first direct `authenticator` it met. The server reads the whole node, and snakeyaml either keeps the last value for a repeated key or rejects the document, so `authenticator: com.example.First` followed by `authenticator: null` reported `named` for a Gremlin that ends up with no authenticator. Block and flow mappings are now read to their end, a single entry is judged on its own value, and a repeated direct child is refused with the line to fix on stderr rather than resolved by whichever row the scan happened to reach first. - A CRLF gremlin-server.yaml -- which is what a config edited on Windows and mounted into the image is -- ended its lines with CR bytes the scanner kept. `authentication:\r` matched no key, so the whole mapping read as `none`: with nothing on the REST side either, check_auth_sides saw two sides agreeing and started a server that authenticates on Gremlin and leaves REST open. Trailing CR is line noise now, taken off before a line is split. props.awk - props_load() read one file while HugeConfig reads a file plus whatever its `include` directive splices into it, so `auth.authenticator` defined in the included file was reported absent from this one, and the spliced order also decides which of two definitions wins. Neither question is answerable from the bytes here, so a file that carries a live `include` is refused in get, has and set (exit 2), nothing is written, and check_auth_sides says plainly that the REST side could not be read instead of sending the operator to the parity message. `included.foo` and a commented `#include=` still read. - The staged rewrite used `.tmp` and `.bak`: predictable names, opened through a truncating redirection that checks nothing, so a mounted conf directory holding either name as a symlink had the default-root entrypoint write credentials through it into another file. Both are exclusively created unpredictable names now (mktemp, 0600 whatever the umask), which is also what removed the two `chmod 600 -- ` calls that BSD chmod reads as a file name. - The odd-backslash guard looked at the encoded value, and `abc\ ` ends in a space, so it passed a value that commons-configuration right-trims into `abc\`, which then swallows the line under it. The guard judges the trimmed form; docker-entrypoint.sh writes a space as \u0020, which both readers decode to the same space and the trimmer has nothing to take. A round trip through java.util.Properties on JDK 17 gives `abc ` back with the following property intact. - enable-auth.sh compared gremlin.graph without trimming, so a mounted factory with a trailing blank was left outside HugeFactoryAuthProxy in a tree both servers believed was authenticated; GraphManager only warns about that. It compares the spelling the server resolves now, and a factory it does not own is still left alone. Not verified here, named rather than assumed: commons-configuration2 and snakeyaml were not executed -- no jars, no local repository, and no Maven resolve on this host -- so the trim and null-resolution behaviours come from the reviewers' measurements plus the documented grammar; mawk is absent, so the awk changes ran under gawk only; the symlinked temp-file case is gated on a host that can make symlinks and did not run here; there is no Docker daemon, so the image was not built. --- .../docker/docker-entrypoint-test.sh | 36 +- .../docker/docker-entrypoint.sh | 22 +- .../docker/test/test-docker-entrypoint.sh | 374 +++++++++++++++++- .../hugegraph-dist/docker/yamlscan.awk | 88 ++++- .../src/assembly/static/bin/enable-auth.sh | 13 + .../src/assembly/static/bin/props.awk | 98 +++-- 6 files changed, 570 insertions(+), 61 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index 3aceea1aff..9b7ce61c9a 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -205,7 +205,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}" @@ -222,8 +222,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" @@ -231,7 +261,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" diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index dcb39c4f1f..1005a2e608 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -74,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" ;; @@ -144,7 +150,7 @@ yaml_auth_state() { # 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 + local rest=0 yaml=0 state rest_value state=$(yaml_auth_state) if [[ "${state}" == "nameless" ]]; then @@ -153,7 +159,17 @@ check_auth_sides() { "to it or remove the mapping, then restart." return 1 fi - if [[ -n "$(get_prop_encoded "auth.authenticator" "${REST_SERVER_CONF}")" ]]; then + # 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 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 63317ac7ee..b66778bd91 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -562,14 +562,19 @@ install_enable_auth "${empty_dir}" # `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 "$*" in' \ - ' *.tmp) printf "auth.authenticator=par"; exit 1 ;;' \ - ' *.bak) [ -n "${FAKE_BAK_FAIL:-}" ] && exit 1' \ + 'case "$2" in' \ + ' *.tmp*) printf "auth.authenticator=par"; exit 1 ;;' \ + ' *.bak*) [ -n "${FAKE_BAK_FAIL:-}" ] && exit 1 ;;' \ 'esac' \ 'exec "${FAKE_CAT_REAL}" "$@"' \ > "${failbin}/cat" @@ -581,6 +586,9 @@ printf '%s\n' \ '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}" @@ -596,16 +604,24 @@ cmp -s "${rb_file}" "${rb_expect}" || { } # Both staging files survive on purpose: the temp file is what was being # written, and the snapshot is the operator's way back. -[[ -e "${rb_file}.tmp" ]] -[[ -e "${rb_file}.bak" ]] +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. +# 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}" -[[ ! -e "${rb_file}.tmp" ]] -[[ ! -e "${rb_file}.bak" ]] +[[ -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. @@ -620,15 +636,15 @@ rb_out=$( export PATH FAKE_CAT_REAL FAKE_BAK_FAIL set_prop 'auth.authenticator' 'com.example.HalfWritten' "${rb_file}" 2>&1 ) || true -[[ "${rb_out}" == *"${rb_file}.bak"* ]] || { - echo "props.awk must name the snapshot when the restore also fails" >&2 +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. -[[ -e "${rb_file}.bak" ]] -[[ -e "${rb_file}.tmp" ]] -cmp -s "${rb_file}.bak" "${rb_expect}" || { +[[ -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 } @@ -1232,3 +1248,335 @@ 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 + } + + # 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 + if PROPS_MODE=has PROPS_KEY=restserver.url PROPS_FILE="${REST_SERVER_CONF}" \ + awk -f "${PROPS_AWK}" /dev/null 2>/dev/null; then + echo "PROPS_MODE=has must refuse too" >&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 + + # 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 index 9daf1b15c0..6c74798557 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -41,7 +41,15 @@ # 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. +# 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; +# 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. # # 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 @@ -139,12 +147,25 @@ function names_authenticator(k) { return unquote(k) == "authenticator" } # - 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) { +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) @@ -157,15 +178,36 @@ function names_class(v, first, last, body) { 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. A key defined twice 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" + return "nameless" +} + # Report and stop. Output happens in END only, because awk runs END after # `exit` and a second print there would emit two states on one run. function finish(r) { RESULT = r; exit } # 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. FSET records a direct -# authenticator that names a class. Returns 1 once the outermost collection -# has closed. +# 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 = "" @@ -210,6 +252,7 @@ function scan_flow(s, i, n, c, q, esc) { FST = "skip" continue } + if (c == "\r") continue if (DEPTH != 1) continue if (c == ":") { if (FST == "key") { @@ -239,7 +282,10 @@ function scan_flow(s, i, n, c, q, esc) { # 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) && names_class(CUR_VAL)) FSET = 1 + if (names_authenticator(k)) { + AUTH_SEEN++ + AUTH_NAMED = names_class(CUR_VAL) + } } BEGIN { @@ -248,7 +294,8 @@ BEGIN { CUR = "" CUR_KEY = "" CUR_VAL = "" - FSET = 0 + AUTH_SEEN = 0 + AUTH_NAMED = 0 found = 0 child = -1 flow = 0 @@ -256,7 +303,12 @@ BEGIN { } { + # A CR here is the terminator of a CRLF line, not content: YAML ends a line + # at either byte, so `authentication:\r` is the key line and leaving the CR + # on it made split_pair see no colon followed by end of line, which reported + # a whole mapping as absent. line = strip_comment($0) + sub(/[ \t\r]+$/, "", line) if (!found) { if (line ~ /^[ \t]/) next @@ -265,7 +317,7 @@ BEGIN { found = 1 if (substr(V_TXT, 1, 1) == "{") { flow = 1 - if (scan_flow(V_TXT)) finish(FSET ? "named" : "nameless") + if (scan_flow(V_TXT)) finish(auth_state()) next } # Anything else on the key line -- a scalar, a sequence, nothing -- is @@ -275,23 +327,29 @@ BEGIN { } if (flow) { - if (scan_flow(line)) finish(FSET ? "named" : "nameless") + if (scan_flow(line)) finish(auth_state()) next } if (trim(line) == "") next # A column-0 line after the comment was stripped is a sibling key, so the - # mapping has ended. - if (indent_of(line) == 0) finish("nameless") + # mapping has ended and what was recorded while reading it is the answer. + if (indent_of(line) == 0) finish(auth_state()) if (!split_pair(line)) next if (child < 0) child = indent_of(line) if (indent_of(line) != child) next - if (names_authenticator(K_TXT) && names_class(V_TXT)) finish("named") + if (names_authenticator(K_TXT)) { + AUTH_SEEN++ + AUTH_NAMED = names_class(V_TXT) + } } END { - if (RESULT != "") { print RESULT; exit } - if (!found) print "none" - else print "nameless" + if (RESULT == "") { + if (!found) RESULT = "none" + else if (flow) RESULT = "nameless" + 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 e090530296..f9d5421b64 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 @@ -207,7 +207,20 @@ ensure_rest_prop "auth.graph_store" "hugegraph" "${CONF}/${REST_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. GRAPH_FACTORY=$(props_get "gremlin.graph" "${CONF}/graphs/${GRAPH_CONF}") +while [[ "${GRAPH_FACTORY}" =~ [[:space:]]$ ]]; do + GRAPH_FACTORY="${GRAPH_FACTORY%?}" +done if [[ "${GRAPH_FACTORY}" == "org.apache.hugegraph.HugeFactory" ]]; then props_set "gremlin.graph" "org.apache.hugegraph.auth.HugeFactoryAuthProxy" \ "${CONF}/graphs/${GRAPH_CONF}" diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk index 244202b821..5770b4df93 100644 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -52,6 +52,15 @@ # 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" @@ -153,6 +162,31 @@ function shquote(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 @@ -237,6 +271,16 @@ function props_load(file, raw, rc, content, nl, stripped, next_raw, start, lo BFIRST[NBLOCK] = start BLAST[NBLOCK] = nl BKEY[NBLOCK] = unescape(K_RAW) + # `include` 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. So a file that + # uses the directive is refused in every mode, and nothing is written. + if (BKEY[NBLOCK] == "include") + die("refusing to read or rewrite " file ": it includes another file (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 @@ -245,19 +289,25 @@ function props_load(file, raw, rc, content, nl, stripped, next_raw, start, lo } } -function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) { +function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs, tail) { props_load(file) - # A value whose encoded form ends in an odd number of backslashes would - # turn the line written after it into a continuation of that value. - # Measured against commons-configuration2 (what HugeConfig extends), the - # same input read back yields no property at all, so a secret written this - # way would never reach the server that is supposed to authenticate with - # it; the entrypoint has to refuse instead of guessing a target. + # 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(enc_val) && substr(enc_val, length(enc_val) - nbs, 1) == "\\") + while (nbs < length(tail) && substr(tail, length(tail) - nbs, 1) == "\\") nbs++ if (nbs % 2 == 1) - die("refusing to write " key ": the encoded value ends in an odd number of backslashes") + die("refusing to write " key ": trimmed of its trailing blanks the value ends in a backslash, which would swallow the next line") first = 0 for (b = 1; b <= NBLOCK; b++) { if (BTYPE[b] == "entry" && BKEY[b] == key) { @@ -265,16 +315,12 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) else BDROP[b] = 1 } } - # Staged rewrite: everything lands in a sibling temp file first, so a - # failure before the copy-back leaves the original untouched. The temp - # file can hold secrets, so it is pre-created 0600 before the first - # write: awk's `>` below would otherwise create it under the process - # umask (usually 0644), leaving auth.admin_pa or auth.token_secret - # briefly group- and world-readable. Truncating an existing file keeps - # its mode, and the chmod after close repairs a stale tmp left behind - # by a crashed run. - tmp = file ".tmp" - system("umask 077 && : > " shquote(tmp)) + # 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) { @@ -305,14 +351,12 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs) # 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 (under - # umask 077 so a backup of a 0644 mounted config never ends up more - # permissive than it started, and chmodded in case a crashed run left - # one behind) and put it back when the copy fails. - system("chmod 600 -- " shquote(tmp)) - bak = file ".bak" - cmd = "umask 077 && cp -- " shquote(file) " " shquote(bak) - if (system(cmd " && chmod 600 -- " shquote(bak)) != 0) + # 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) { From 5940fa5e3691cabb08a500a0ffb2f66c192b2bee Mon Sep 17 00:00:00 2001 From: Adarsh Date: Thu, 24 Sep 2026 22:06:35 +0530 Subject: [PATCH 13/22] fix(docker): fail closed on layouts the guards resolved too optimistically Three findings from imbajin's re-review at cc9cc8c. Each was reproduced against the previous commit first, then the test was reverted to confirm it goes red without the fix; both CI-wired suites exit 0 after the fix. props.awk -- commons configuration 2 splices another file on BOTH `include` and `includeOptional`, and matches the directive name case-insensitively, so rejecting only the exact lowercase `include` let `INCLUDE=`, `IncludeOptional=` and friends read and rewrite as ordinary properties: auth.authenticator defined in the spliced file stayed invisible, which is the REST-open/Gremlin-protected boot the guard exists to stop. Every spelling a real loader honours is now refused in all modes (exit 2), with `included.filter` and a commented `#include` kept as controls that still read. yamlscan.awk -- two unsafe answers, both from trusting column 0 and the first mapping: - two top-level `authentication` mappings resolved through the FIRST one, so a config the empty second mapping leaves on AllowAllAuthenticator reported `named`. The scanner now reads to EOF and refuses a duplicate root mapping (nameless, with the count on stderr) the same way it already refuses a duplicate direct authenticator. - a root mapping written indented below a document marker -- valid to Settings.read() -- was skipped and reported `none`, the opposite mismatch. "column 0" became "the indentation of the document root", learned from the first key:an-indented `authentication:` nested under another real key is still not the server's, and still reports `none`. docker-entrypoint-test.sh -- server-ci.yml exports BACKEND=rocksdb for the only matrix leg that runs these tests, the entrypoint maps it to HG_SERVER_BACKEND, and that overwrote the fixture's escaped `h\u0073tore`, so the stabilization assertion failed in CI while passing locally. The harness now unsets the inherited backend/pd environment; production precedence (env beats file) is untouched and cases that mean to drive it from the env set it per invocation. Not verifiable on this host: commons-configuration2 and snakeyaml were not executed (no jars, no ~/.m2) -- the include spellings and the last-wins mapping resolution follow imbajin's reproduction against the real parsers, and the shell-side behaviour of each fix is measured here. --- .../docker/docker-entrypoint-test.sh | 12 +++ .../docker/test/test-docker-entrypoint.sh | 49 +++++++++ .../hugegraph-dist/docker/yamlscan.awk | 100 +++++++++++++----- .../src/assembly/static/bin/props.awk | 17 +-- 4 files changed, 147 insertions(+), 31 deletions(-) diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index 9b7ce61c9a..54d289bf61 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -17,6 +17,18 @@ # 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 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 b66778bd91..e9abeb83c3 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -999,6 +999,33 @@ yaml_case nameless "flow value that is an empty quoted string" \ 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, +# or rejects the file, so the first must not decide the answer. Reporting +# `named` for a config whose empty second mapping leaves Gremlin on +# AllowAllAuthenticator is the same unsafe direction the duplicate-authenticator +# case refuses for -- so read to EOF and refuse. +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' +# 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 @@ -1431,6 +1458,28 @@ mkdir -p "${include_dir}/conf" 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}" diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index 6c74798557..41bf064f80 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -28,8 +28,10 @@ # snakeyaml hands to the server, within the subset of YAML that shipped and # mounted configs use: # -# 1. the mapping must start at column 0 -- an `authentication:` nested under -# some other key belongs to that feature, not to the Gremlin server; +# 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 @@ -45,6 +47,11 @@ # 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 (or are +# rejected outright). Answering from the first reported `named` for a +# server left on AllowAllAuthenticator by the empty second mapping, so a +# duplicate root mapping is refused rather than guessed at; # 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 @@ -199,9 +206,19 @@ function auth_state( msg) { return "nameless" } -# Report and stop. Output happens in END only, because awk runs END after -# `exit` and a second print there would emit two states on one run. -function finish(r) { RESULT = r; exit } +# The answer when the file carries more than one top-level `authentication` +# mapping. SnakeYAML either takes the last one or, with unique keys enforced, +# rejects the document and the server never starts -- either way the effective +# mapping is not the first the scanner met, so this cannot be answered here. +# Report it on stderr and refuse through the nameless state, exactly like the +# duplicate-authenticator case above, so check_auth_sides stops the boot. +function duplicate_root( msg) { + msg = "yamlscan.awk: " AUTH_BLOCKS " top-level authentication mappings" + print msg > "/dev/stderr" + print "cannot be answered here: the server takes the last one, or rejects the file." > "/dev/stderr" + print "Keep a single top-level authentication mapping in gremlin-server.yaml." > "/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 @@ -296,7 +313,9 @@ BEGIN { CUR_VAL = "" AUTH_SEEN = 0 AUTH_NAMED = 0 - found = 0 + ROOT_IND = -1 + AUTH_BLOCKS = 0 + in_auth = 0 child = -1 flow = 0 RESULT = "" @@ -309,16 +328,45 @@ BEGIN { # a whole mapping as absent. line = strip_comment($0) sub(/[ \t\r]+$/, "", line) + if (trim(line) == "") next + + ind = indent_of(line) - if (!found) { - if (line ~ /^[ \t]/) next + # 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) { if (!split_pair(line)) next - if (unquote(K_TXT) != "authentication") next - found = 1 + ROOT_IND = ind + } else if (ind == ROOT_IND && in_auth) { + # A root-level sibling closes the mapping being read. + 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: two top-level mappings resolve to + # the last one (or are rejected), and answering from the first reported + # `named` for a server the empty second mapping left open. + if (ind == ROOT_IND && split_pair(line) && + unquote(K_TXT) == "authentication") { + AUTH_BLOCKS++ + if (AUTH_BLOCKS > 1) { + AUTH_SEEN = 0 + AUTH_NAMED = 0 + } + in_auth = 1 + child = -1 if (substr(V_TXT, 1, 1) == "{") { flow = 1 - if (scan_flow(V_TXT)) finish(auth_state()) - next + if (scan_flow(V_TXT)) { + 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` @@ -326,19 +374,23 @@ BEGIN { next } + if (!in_auth) next + if (flow) { - if (scan_flow(line)) finish(auth_state()) + if (scan_flow(line)) { + in_auth = 0 + flow = 0 + } next } - if (trim(line) == "") next - # A column-0 line after the comment was stripped is a sibling key, so the - # mapping has ended and what was recorded while reading it is the answer. - if (indent_of(line) == 0) finish(auth_state()) - + # 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 (!split_pair(line)) next - if (child < 0) child = indent_of(line) - if (indent_of(line) != child) next + if (child < 0) child = ind + if (ind != child) next if (names_authenticator(K_TXT)) { AUTH_SEEN++ AUTH_NAMED = names_class(V_TXT) @@ -346,10 +398,8 @@ BEGIN { } END { - if (RESULT == "") { - if (!found) RESULT = "none" - else if (flow) RESULT = "nameless" - else RESULT = auth_state() - } + if (AUTH_BLOCKS == 0) RESULT = "none" + else if (AUTH_BLOCKS > 1) RESULT = duplicate_root() + else RESULT = auth_state() print RESULT } diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk index 5770b4df93..c5687310a2 100644 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -229,7 +229,7 @@ function scan_records(s, i, n, c, start, term, len, 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) { +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" @@ -271,16 +271,21 @@ function props_load(file, raw, rc, content, nl, stripped, next_raw, start, lo BFIRST[NBLOCK] = start BLAST[NBLOCK] = nl BKEY[NBLOCK] = unescape(K_RAW) - # `include` is not an ordinary property to the server: commons + # 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. So a file that - # uses the directive is refused in every mode, and nothing is written. - if (BKEY[NBLOCK] == "include") - die("refusing to read or rewrite " file ": it includes another file (line " start "), which this helper cannot resolve") + # 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 From c1dde5e1c4ced33017fd46b69a5750abda858290 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Fri, 25 Sep 2026 11:49:58 +0530 Subject: [PATCH 14/22] fix(docker): read the yaml shapes the scanner still answered wrongly Six findings from imbajin's round at 5940fa5 plus Copilot's block-scalar one. Each input was run against the scanner at 5940fa5 and against the fixed one before anything was claimed; the table is the evidence, and the new suite cases revert red the same way. case 5940fa5 fixed note spread_flow_named nameless named closing brace read as sibling escaped_root_key none named "authentic\u0061tion" is the key escaped_child_key nameless named same, on the direct child unresolvable_escape none nameless refused, not missed block_empty named nameless `authenticator: |` with no body root_flow none nameless refused, see below bare_cr none named one record, key never met Controls measured unchanged: plain block named, nested mapping none, inline flow named, handler-only block nameless, blank line inside a mapping named, quoted scalar with a trailing comment named. Both CI-wired suites exit 0, with output byte-identical to the pre-change baseline apart from the new cases. yamlscan.awk - A flow mapping spread over several lines closed at the root indentation, and the sibling test fired before the flow branch could see the `}`: the pending entry was never committed, so a config naming a class read as nameless and the boot was refused. The sibling test now yields while a flow is open. Refusing a valid mounted config is its own bug, so the nameless-without-a-class form of the same layout is pinned as a control that the fix is not a blanket pass. - unquote() compared quoted bytes without resolving them. A double quoted scalar is unescaped by snakeyaml before it is a key, so "authentic\u0061tion" IS the authentication key and "authentic\u0061tor" the direct child; missing them answered none / nameless for a server that does authenticate, the direction this whole guard exists to close. The documented escapes now decode through hexval(), and an escape this reader cannot resolve sets UNRESOLVED and refuses the document rather than quietly failing to match. - CR was stripped at the end of a record only, so a file whose lines end at a bare CR arrived as one record whose root key was never met at all. Records are split on CR now, which covers CR, LF and CRLF from one rule; the CR that the Linux reader leaves at the end of a CRLF record yields the empty segment the blank check already drops. - names_class() judged a block scalar by its indicator, so `authenticator: |` with nothing behind it -- an empty string to the server, hence no authenticator -- reported named. The deeper lines are now collected as the value, which keeps `authenticator: |` over a class name naming that class. - A document written as one flow mapping is refused rather than classified. Parsing it means reading a depth-two authenticator behind a root key, and the wrong answer there is the silent one, so it takes imbajin's stated second option and fails closed with the reason on stderr. This is a refusal of a form no shipped or documented config uses; say so if the block-form requirement is unwanted and the parser is the better half. Not executed here, named rather than assumed: snakeyaml was never run, so the escape-resolution and block-scalar readings follow the resolver's documented behaviour and imbajin's reproduction against it, not a run of it; mawk is not on this host, so the awk changes ran under GNU awk 5.0.0 only while CI reaches them on Ubuntu; there is no Docker daemon, so no image was built and the mounted- volume layout Copilot flags at Dockerfile:75 is untouched by this commit. --- .../docker/test/test-docker-entrypoint.sh | 75 +++++++ .../hugegraph-dist/docker/yamlscan.awk | 190 ++++++++++++++++-- 2 files changed, 245 insertions(+), 20 deletions(-) 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 e9abeb83c3..060bbd1e5d 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -262,6 +262,81 @@ mkdir -p "${yaml_dir}/conf" > "${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)" ) diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index 41bf064f80..4633d3e2c3 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -103,13 +103,83 @@ function indent_of(s, i, n, c) { 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) { +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) - return substr(s, 2, length(s) - 2) + 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 } @@ -305,6 +375,31 @@ function commit_val( k) { } } +# 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]*[-+]?$/ +} + BEGIN { DEPTH = 0 FST = "key" @@ -319,16 +414,37 @@ BEGIN { child = -1 flow = 0 RESULT = "" + UNRESOLVED = 0 + ROOT_FLOW = 0 + BLOCK = 0 + BLOCK_IND = 0 + BLOCK_TXT = "" } -{ - # A CR here is the terminator of a CRLF line, not content: YAML ends a line - # at either byte, so `authentication:\r` is the key line and leaving the CR - # on it made split_pair see no colon followed by end of line, which reported - # a whole mapping as absent. - line = strip_comment($0) - sub(/[ \t\r]+$/, "", line) - if (trim(line) == "") next +# 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) { + if (BLOCK) { + # 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) @@ -339,10 +455,21 @@ BEGIN { # 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) { - if (!split_pair(line)) next + # 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) { - # A root-level sibling closes the mapping being read. + } 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 @@ -371,34 +498,57 @@ BEGIN { # 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. - next + return } - if (!in_auth) next + if (!in_auth) return if (flow) { if (scan_flow(line)) { in_auth = 0 flow = 0 } - next + 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 (!split_pair(line)) next + if (!split_pair(line)) return if (child < 0) child = ind - if (ind != child) next + 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 + } 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. + seg_n = split($0, seg, /\r/) + for (seg_i = 1; seg_i <= seg_n; seg_i++) handle_line(seg[seg_i]) +} + END { - if (AUTH_BLOCKS == 0) RESULT = "none" + 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") + else if (AUTH_BLOCKS == 0) RESULT = "none" else if (AUTH_BLOCKS > 1) RESULT = duplicate_root() else RESULT = auth_state() print RESULT From b42b1ba0ca644668d5f8473cb4b6c2f3b1df702c Mon Sep 17 00:00:00 2001 From: Adarsh Date: Fri, 25 Sep 2026 22:00:29 +0530 Subject: [PATCH 15/22] fix(docker): refuse to arm REST when the yaml names no authenticator enable-auth.sh asked only whether an `authentication` mapping was there. For a mapping that names no authenticator -- one carrying just authenticationHandler -- that answer sent it past the Gremlin append and straight into writing auth.authenticator, so the script produced the exact one-sided boot it exists to prevent: REST enforcing StandardAuthenticator beside a Gremlin that authenticates nothing. The entrypoint hides this today because check_auth_sides refuses the same tree first, but the release tarball ships enable-auth.sh with no yamlscan.awk and nothing in front of it, so the refusal belongs here rather than leaned on the caller. The guard now answers three ways through the same reader check_auth_sides uses -- none, named, nameless -- because "is a mapping present" and "does it name a class" are different questions and only the second decides what is safe to write. yamlscan.awk's refusal states (root flow mapping, unresolvable escape, duplicate blocks) arrive as nameless and are refused here too instead of being answered with a REST-only write. In the tarball layout grep can only see the key, so a mapping it cannot verify is refused unless the operator has already named a class on the REST side, which is the one case where ensure_rest_prop writes nothing and the tree is left as found. Executed here: both CI-wired suites exit 0 at this commit -- test-docker-entrypoint.sh, and docker-entrypoint-test.sh with byte-identical verdicts before and after. The new assertions were run against the previous script and fail with "enable-auth.sh must refuse a mapping that names no authenticator", so they discriminate rather than track the fix. enable-auth.sh run by hand on a handler-only mapping in both layouts now exits 1 with rest-server.properties still empty and gremlin-server.yaml and hugegraph.properties byte-identical to before; against the old script the same command exited 0 and left auth.authenticator written. The suite was also run from the staged LF blobs, not only the CRLF worktree, because CI checks out LF. Not executed here, named rather than assumed: there is no Docker daemon and no Java on this host, so no container booted and no server read the yaml -- that TinkerPop 3.5.1 resolves an omitted authenticator to AllowAllAuthenticator is imbajin's statement and his reproduction, not an observation of mine. mawk is absent, so this ran under GNU awk only. --- .../docker/test/test-docker-entrypoint.sh | 82 +++++++++++++------ .../src/assembly/static/bin/enable-auth.sh | 68 +++++++++++---- 2 files changed, 109 insertions(+), 41 deletions(-) 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 060bbd1e5d..a7e3f8e6f8 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -397,40 +397,74 @@ mkdir -p "${sides_dir}/conf" must_refuse "the yaml mapping names no authenticator and REST does" ) -# The refusal above is what keeps enable-auth.sh from writing one side: -# against the same ambiguous layout, enable-auth.sh on its own writes only -# the REST file (its yaml guard already sees an `authentication:` line), -# leaving REST on StandardAuthenticator and Gremlin on TinkerPop's -# AllowAllAuthenticator default. The entrypoint never lets it run there -# because check_auth_sides fails first under set -e. +# 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" -mkdir -p "${onesided_dir}/bin" "${onesided_dir}/conf/graphs" -install_enable_auth "${onesided_dir}" +nameless_tree "${onesided_dir}" ( cd "${onesided_dir}" || exit 1 REST_SERVER_CONF="./conf/rest-server.properties" - : > conf/rest-server.properties - printf '%s\n' \ - 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ - > conf/graphs/hugegraph.properties - printf '%s\n' \ - 'authentication:' \ - ' authenticationHandler: org.apache.hugegraph.auth.WsAndHttpBasicAuthHandler' \ - > conf/gremlin-server.yaml if check_auth_sides; then echo "check_auth_sides must refuse a yaml mapping without an authenticator" >&2 exit 1 fi - ./bin/enable-auth.sh - grep -q '^auth\.authenticator=org\.apache\.hugegraph\.auth\.StandardAuthenticator$' \ - conf/rest-server.properties - grep -q 'HugeFactoryAuthProxy' conf/graphs/hugegraph.properties - if grep -Eq '^[[:blank:]]*authenticator[[:blank:]]*:' conf/gremlin-server.yaml; then - echo "enable-auth.sh must not add an authenticator to the yaml block" >&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. 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 f9d5421b64..065e92871f 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 @@ -163,37 +163,71 @@ append_lines() { AUTHENTICATOR_CLASS="${AUTHENTICATOR_CLASS:-org.apache.hugegraph.auth.StandardAuthenticator}" -# Does the Gremlin config already carry a top-level `authentication` mapping? -# 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. Anything other than `none` means a mapping is there and the -# append is not this script's to make. -gremlin_has_auth_block() { - local file="$1" state - [[ -f "${file}" ]] || return 1 +# 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 - state=$(awk -f "${YAMLSCAN}" "${file}") || fail "cannot read ${file}" - [[ "${state}" != "none" ]] - return + 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. - grep -Eq "^[\"']?authentication[\"']?[[:blank:]]*:" "${file}" + 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 + +if [[ "${GREMLIN_AUTH}" == "unverifiable" ]] && + [[ -z "$(props_get "auth.authenticator" "${CONF}/${REST_SERVER_CONF}")" ]]; 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_has_auth_block "${CONF}/${GREMLIN_SERVER_CONF}"; then +if [[ "${GREMLIN_AUTH}" == "none" ]]; then append_lines "${CONF}/${GREMLIN_SERVER_CONF}" \ 'authentication: {' \ " authenticator: ${AUTHENTICATOR_CLASS}," \ From a5de9f6bf1b612a4f1e97a5d080d9d911712cb88 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Sun, 27 Sep 2026 04:42:55 +0530 Subject: [PATCH 16/22] fix(docker): reject an unusable AUTHENTICATOR_CLASS before writing it The character check for a class name lived only in props_set, which first runs at the rest-server.properties write -- one statement after the yaml block was appended carrying that same ${AUTHENTICATOR_CLASS}. A value the check rejects therefore left the tree one-sided, which is the state this script exists to prevent: AUTHENTICATOR_CLASS='com.example.My Auth' exited 1 with gremlin-server.yaml naming com.example.My Auth (yamlscan.awk answers `named`) while rest-server.properties stayed empty and hugegraph.properties kept the plain HugeFactory. The entrypoint's check_auth_sides reads that tree as REST-unconfigured beside a Gremlin that names a class and refuses the boot, so the refused run does not merely fail -- it arms a config the container will not start in. It also did not self-repair, which is what makes it worse than a messy failure: once the yaml names a class the append is skipped, so re-running with the variable unset wrote auth.authenticator=StandardAuthenticator to REST, exited 0 and reported success with the two servers configured for different authenticators. Measured on the same tree, not inferred. The check is now one function, so the early refusal and the write cannot drift apart, and it runs as soon as the value is known and before any config is touched. props_set still calls it; the yaml append can no longer be reached by a value it would refuse. Executed here: test-docker-entrypoint.sh exits 0 with the new assertions, and exits 1 without the fix in both layouts it now covers -- "image layout (yamlscan.awk present): a refused class still edited gremlin-server.yaml" and "release tarball (no yamlscan.awk): a refused class still edited gremlin-server.yaml" -- so they discriminate rather than track the fix. docker-entrypoint-test.sh exits 0 with verdict lines identical before and after. enable-auth.sh run by hand with the bad class now exits 1 with both files untouched and the yaml still `none`, and the following run with the variable unset arms both sides with the same class; against the previous script the same command left the yaml written and the rerun split. Run from the staged LF blobs, as CI checks out LF, under GNU awk 5.0.0. Not executed here, named rather than assumed: the four host-gated assertion groups (CRLF bytes, config-mode preservation, symlinked config, symlinked temp file) skip on this Windows host and run only under CI, so this is again GNU awk plus MSYS coreutils rather than mawk or BWK awk; there is no Docker daemon and no Java here, so no container was booted and neither server read these configs -- that check_auth_sides refuses the one-sided tree is shown by calling that function on the tree the old script left, and what TinkerPop and commons-configuration do with the result is bitflicker64's reproduction, not a run of it. The six workflow runs at b42b1ba are still action_required, so CI has not run on this commit either. --- .../docker/test/test-docker-entrypoint.sh | 72 +++++++++++++++++++ .../src/assembly/static/bin/enable-auth.sh | 28 ++++++-- 2 files changed, 94 insertions(+), 6 deletions(-) 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 a7e3f8e6f8..7c21399cb5 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -622,6 +622,78 @@ class_dir="${test_dir}/authenticator-class" "${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 `$` 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 065e92871f..e7d5e6d6bd 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 @@ -89,13 +89,18 @@ props_get() { printf '%s' "${value}" } -props_set() { - # The only values written here are Java class names, whose characters need - # no properties escaping; anything else would have to go through the - # entrypoint's encoder first. - case "$2" in - *[!A-Za-z0-9_\.\$]*) fail "refusing to write an unescaped value: $2" ;; +# 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" } @@ -163,6 +168,17 @@ append_lines() { 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 From be7f5e27ca7f6014f8af553c1950e14c08fb8346 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Sun, 27 Sep 2026 11:46:17 +0530 Subject: [PATCH 17/22] fix(docker): read a flow collection that a child value leaves open yamlscan.awk answered `named` for a gremlin-server.yaml whose only authenticator sits inside a multi-line flow mapping under `config`: authentication: config: {tokens: conf/rest-server.properties, authenticator: org.apache.hugegraph.auth.StandardAuthenticator} Flow content ignores indentation, so the third line belongs to `config` and Settings.read() hands the server the AllowAllAuthenticator default for authentication.authenticator. The block branch read each line on its own and took that continuation line for a direct child, which is the unsafe direction for this reader: on a tree carrying that file, enable-auth.sh exited 0, skipped the yaml append because the reader said the mapping already named a class, and wrote auth.authenticator=StandardAuthenticator to rest-server.properties; check_auth_sides then passed on every boot, with REST enforcing and Gremlin answering unauthenticated. Measured rather than inferred -- the same tree now exits 1 with rest-server.properties still empty and check_auth_sides refusing the boot, which is what the guard is for. A quoted scalar left open by a child value has the same effect, so it is tracked the same way: the lines until it closes are nested content, at any indentation, and never a direct child. The span is ended where the collection or the quote closes rather than at the end of the mapping, so a direct `authenticator` written after a multi-line `config` still counts, and a collection that never closes at all is refused through the nameless state, since a file the server rejects has no answer this reader can give honestly. Two valid spellings failed the other way and are fixed in the same pass. `authentication: &auth {authenticator: X}` is a flow mapping behind an anchor, and a flow mapping may open on the first child line instead of on the key line; both load with the class named, and the `{` test on the raw value read neither, so check_auth_sides stopped a container that master boots while reporting that the mapping names no authenticator -- which points the operator at the wrong line. unanchor() now precedes the brace test, and a flow collection opening on the first child line goes to the same scan_flow() that reads the other form, because its braces hold the direct entries. Executed here, from the staged LF blobs under GNU awk 5.0.0: every shape above against both versions of the scanner -- five of the six new assertions return the wrong state at a5de9f6 and the right one with the fix (the sixth, a direct child after a closed span, answers `named` on both, so it guards against over-swallowing rather than against this bug), and eight controls that must not move did not (nested `config` on one line, a direct child, a null value, a multi-line flow as the authentication value itself, a sequence child, a sibling mapping with its own authenticator, a commented-out entry, no mapping at all). test-docker-entrypoint.sh exits 0 with the new assertions and exits 1 with yamlscan.awk reverted and the tests kept, failing on "expected yaml state 'nameless', got 'named'". docker-entrypoint-test.sh exits 0 before and after. bash -n and gawk --lint clean; staged blobs byte-checked for CRs, since this clone has core.autocrlf=true. Not executed here, named rather than assumed: the four host-gated assertion groups (CRLF bytes, config-mode preservation, symlinked config, symlinked temp file) skip on this Windows host and run only under CI; only gawk is installed, so mawk and BWK awk are not exercised; there is no Docker daemon and no Java here, so no container was booted and no server parsed these configs -- the answers Settings.read() gives are bitflicker64's reproduction on snakeyaml 1.27 and not a run of it. CI has not run on this commit: the runs at a5de9f6 were still action_required when this was written. --- .../docker/test/test-docker-entrypoint.sh | 98 ++++++++++++++++ .../hugegraph-dist/docker/yamlscan.awk | 107 +++++++++++++++++- 2 files changed, 201 insertions(+), 4 deletions(-) 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 7c21399cb5..dd95965220 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -582,6 +582,104 @@ mkdir -p "${scope_dir}/conf" 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. diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index 4633d3e2c3..baf055280d 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -57,6 +57,17 @@ # 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. # # 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 @@ -400,6 +411,51 @@ function is_block_scalar(v) { 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) +} + +# 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" @@ -419,6 +475,9 @@ BEGIN { BLOCK = 0 BLOCK_IND = 0 BLOCK_TXT = "" + SP = 0 + SQ = "" + ESC = 0 } # Close out the block scalar whose lines were being collected. @@ -428,7 +487,7 @@ function finish_block() { BLOCK_TXT = "" } -function handle_line(raw, line, ind) { +function handle_line(raw, line, ind, v) { if (BLOCK) { # Deeper than the key means the line is still scalar content; anything # else ends the scalar and is ordinary content again. @@ -448,6 +507,16 @@ function handle_line(raw, line, ind) { 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 + } + # 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 @@ -488,9 +557,12 @@ function handle_line(raw, line, ind) { } in_auth = 1 child = -1 - if (substr(V_TXT, 1, 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_TXT)) { + if (scan_flow(v)) { in_auth = 0 flow = 0 } @@ -515,8 +587,31 @@ function handle_line(raw, line, ind) { # 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 (!split_pair(line)) return + 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++ @@ -548,6 +643,10 @@ END { 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 (AUTH_BLOCKS == 0) RESULT = "none" else if (AUTH_BLOCKS > 1) RESULT = duplicate_root() else RESULT = auth_state() From 664dcac787c36b5b2a69ad3044343022173f82d6 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Sun, 27 Sep 2026 17:08:50 +0530 Subject: [PATCH 18/22] fix(docker): answer yamlscan with what Settings.read() actually loads bitflicker64 reported three shapes where the scanner and the server disagree. All three reproduce, and this time against the server itself: TinkerPop 3.5.1 Settings.read() on snakeyaml 1.27 (hugegraph-server/pom.xml tinkerpop.version, TinkerPop 3.5.1 snakeyaml.version) run under JDK 17 on the files below, next to this scanner. 1. Two top-level authentication mappings were refused outright. That is the file the base enable-auth.sh leaves behind: it appended a block inside `if [ ! -d conf-bak ]`, and conf-bak/ is not on the mounted volume, so every fresh container over a bind-mounted conf/ added another one (#3133, the bug this PR fixes). Running the base script twice over the shipped conf/ gives two identical blocks, and Settings.read() on that file returns StandardAuthenticator -- master boots it authenticated. duplicate_root() answered nameless, so check_auth_sides stopped the boot on every start, with or without PASSWORD, and the entrypoint then told the operator to add an authenticator entry the file already had. The last mapping is now the one answered, which is the node the server loads: handle_line() already cleared AUTH_SEEN/AUTH_NAMED when a later block opened, so this only removes the override that threw that work away. A class-less LAST mapping still reads nameless and still stops the boot, which is the direction that matters. 2. `authenticator:` with the class on the following deeper line is a folded plain scalar to YAML, and Settings.read() returns the class. The scanner read the empty key line, answered nameless and refused a container that boots. The key now waits for that line, plain or quoted, and a value there that is a mapping or a collection -- which names no class and makes the server throw ConstructorException -- is refused rather than read as a class. 3. `? authentication` with its `: ...` value line is an explicit key, which this reader does not walk. It answered `none` while Settings.read() returned StandardAuthenticator, and with rest-server.properties unconfigured check_auth_sides saw two empty sides and let the container start: REST open beside an authenticating Gremlin, the direction the file comments call unsafe. It is refused now, through the same refuse() as the root flow mapping. Executed here, from the staged LF blobs under GNU awk 5.0.0: 14 yaml shapes run through both versions of the scanner AND through the real Settings.read(). Five shapes where the server loads a class (two identical blocks, the base script's own two-block output, a block whose second mapping names a class, a value on the next line plain and quoted) returned nameless at be7f5e27 and named with the fix; one explicit-key shape returned `none` at be7f5e27 and now refuses, which is the safe answer for a file the reader cannot classify. Six controls that must not move did not: an empty LAST mapping after a named one (server AllowAllAuthenticator -> still nameless), an authenticator left empty with a sibling below (server null -> nameless), a nested mapping as the value (server ConstructorException -> nameless), a single named mapping, a single null one, and a duplicate authenticator child. End to end, over a staged home with the entrypoint and stubbed bin/: the duplicated file and the next-line value exit 1 at be7f5e27 with start-hugegraph.sh never reached and exit 0 with it called now; the explicit-key file with the REST side unconfigured is the inverse -- it REACHED start-hugegraph.sh at be7f5e27 (the unsafe boot) and exits 1 now. test-docker-entrypoint.sh exits 0 with the seven new assertions and exits 1 with yamlscan.awk reverted to be7f5e27 and the tests kept, failing first on "two identical root mappings name the class: got nameless, want named"; docker-entrypoint-test.sh exits 0 before and after. bash -n and gawk --lint clean, staged blobs byte-checked at 0 CR (grep -c with a CR pattern is useless under MSYS grep -- it matches every line; tr -cd '\r' | wc -c is the check). Left alone on purpose: a duplicate `authenticator` key INSIDE one mapping is still refused even though the server takes the last of those too. It is not what any shipped script produces, the two entries can name different classes, and the existing message names the line to fix. Say so if it should follow the same rule as the root duplicate. Not executed here, named rather than assumed: there is no Docker daemon and no real server process, so nothing bound a port -- Settings.read() is the loader, not the whole boot. The jars (gremlin-server, gremlin-core, snakeyaml 1.27, netty, slf4j) were fetched from Maven Central at the versions this repo declares and run only to parse these files. Only gawk is installed, so mawk and BWK awk are not exercised; the four host-gated assertion groups still skip on Windows; bitflicker64's other open threads (volume, `|| true` on the backend read, class parity) are untouched, and the PR body still describes propagating an authenticator to the missing side, which the code no longer does. CI has not run on this commit: the six runs at be7f5e27 were still action_required when it was written. --- .../docker/test/test-docker-entrypoint.sh | 54 +++++++- .../hugegraph-dist/docker/yamlscan.awk | 125 +++++++++++++----- 2 files changed, 145 insertions(+), 34 deletions(-) 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 dd95965220..e3569ffc4f 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -1279,10 +1279,13 @@ 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, -# or rejects the file, so the first must not decide the answer. Reporting -# `named` for a config whose empty second mapping leaves Gremlin on -# AllowAllAuthenticator is the same unsafe direction the duplicate-authenticator -# case refuses for -- so read to EOF and refuse. +# 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' \ @@ -1290,6 +1293,49 @@ yaml_case nameless "duplicate root authentication mappings" \ ' 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 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. diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index baf055280d..a0e3e2fc44 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -48,10 +48,14 @@ # 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 (or are -# rejected outright). Answering from the first reported `named` for a -# server left on AllowAllAuthenticator by the empty second mapping, so a -# duplicate root mapping is refused rather than guessed at; +# 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 @@ -68,6 +72,18 @@ # (`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. # # 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 @@ -268,13 +284,15 @@ function names_class(v, first, last, body, rest) { # 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. A key defined twice 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. +# 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" @@ -284,20 +302,15 @@ function auth_state( msg) { return "nameless" } if (AUTH_SEEN == 1 && AUTH_NAMED) return "named" - return "nameless" -} - -# The answer when the file carries more than one top-level `authentication` -# mapping. SnakeYAML either takes the last one or, with unique keys enforced, -# rejects the document and the server never starts -- either way the effective -# mapping is not the first the scanner met, so this cannot be answered here. -# Report it on stderr and refuse through the nameless state, exactly like the -# duplicate-authenticator case above, so check_auth_sides stops the boot. -function duplicate_root( msg) { - msg = "yamlscan.awk: " AUTH_BLOCKS " top-level authentication mappings" - print msg > "/dev/stderr" - print "cannot be answered here: the server takes the last one, or rejects the file." > "/dev/stderr" - print "Keep a single top-level authentication mapping in gremlin-server.yaml." > "/dev/stderr" + # 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" } @@ -478,6 +491,10 @@ BEGIN { SP = 0 SQ = "" ESC = 0 + PENDING = 0 + PENDING_IND = 0 + EXPLICIT = 0 + NESTED_VAL = 0 } # Close out the block scalar whose lines were being collected. @@ -487,7 +504,7 @@ function finish_block() { BLOCK_TXT = "" } -function handle_line(raw, line, ind, v) { +function handle_line(raw, line, ind, v, t, f) { if (BLOCK) { # Deeper than the key means the line is still scalar content; anything # else ends the scalar and is ordinary content again. @@ -517,6 +534,39 @@ function handle_line(raw, line, ind, v) { 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 + } + # 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 @@ -545,15 +595,18 @@ function handle_line(raw, line, ind, v) { } # A top-level authentication key opens a mapping. Count them and read to - # EOF rather than exiting at the first: two top-level mappings resolve to - # the last one (or are rejected), and answering from the first reported - # `named` for a server the empty second mapping left open. + # 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 @@ -623,6 +676,13 @@ function handle_line(raw, line, ind, v) { 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) } } @@ -647,8 +707,13 @@ END { # 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 (NESTED_VAL) + RESULT = refuse("an authenticator whose value is a nested mapping or collection") else if (AUTH_BLOCKS == 0) RESULT = "none" - else if (AUTH_BLOCKS > 1) RESULT = duplicate_root() + # 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 } From 364d42f3621c7c844acb3a27556d616c5796d5b0 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Mon, 28 Sep 2026 19:20:23 +0530 Subject: [PATCH 19/22] fix(docker): read a BOM, a node property and a CR as SnakeYAML does bitflicker64's 2026-09-28 review reported three shapes where this scanner and the server disagree, and all three reproduce at 664dcac against the server's own loader (TinkerPop 3.5.1 Settings.read() on snakeyaml 1.27 under JDK 17). 1. A UTF-8 byte order mark before the first key. The mark frames the stream; SnakeYAML skips it, so "authentication" IS the root mapping. Compared byte-for-byte here, the mark made the key unknown, the answer was `none`, and with rest-server.properties unconfigured check_auth_sides saw two empty sides and started the container: REST open beside a Gremlin that authenticates -- the one direction this reader exists to close. strip_bom() now drops it from the first record, in whichever form the host's awk hands it over: the three bytes under a byte-oriented locale, the single code point under a multibyte one, each guarded by the length its own form has so it cannot mis-fire. 2. A node property in front of the key -- "!!str authentication", "&k authentication", "*a authentication". SnakeYAML resolves the property off the key and builds the mapping; the reviewer measured the class back out of all three. Resolving node properties is not what this reader does, so like the "? " explicit key the mapping it would open is refused rather than called unauthenticated. Narrowed against the literal request, deliberately: only a property in front of THAT key is refused. "!!str host:" or "&defaults handler_pool:" is an ordinary root sibling, and Settings.read() takes no authentication mapping from it -- measuring the whole file, the version that refuses any root key starting with ! & * answered `nameless` for a file whose server side loads com.example.ShapeAuth, which stops a container whose two sides already agree. Refusing that was a new false positive, so the guard tests the key the answer depends on. Say so if you want the literal form anyway. 3. A block scalar on a CRLF file. Rule 7 splits each LF record at the CR, which leaves an empty segment after every line, and the block branch took that empty segment for the end of the scalar: "authenticator: >-" closed with no text before its content line arrived, so a Windows-saved config that does name a class answered `nameless` and check_auth_sides refused its own boot. An empty line never ends a block scalar in YAML, so an empty segment now returns without closing it. The reviewer called this "fails closed, so not a security problem", and it is still worth fixing: it is a valid config that cannot boot. Executed here, from the staged LF blobs under GNU awk 5.0.0. Fourteen fixture files run through the real Settings.read() and through both versions of the scanner: five shapes the server loads or rejects (BOM + flow, BOM + block, the tag, the anchor, the alias, plus the tag-and-anchor pair) answered `none` or `nameless` at 664dcac and now answer `named` or refuse; the CRLF block scalar answered `nameless` at 664dcac while the server loads the class, and answers `named` now. Seven controls did not move: the LF and real-CRLF spellings of the same block scalar, a plain named mapping, a plain none, a class-less mapping, the AllowAll shipped conf, and both non-authentication siblings (tagged and anchored) which stay `named` -- that last pair is what the narrowing above buys, and at 664dcac the tagged sibling was already right, so the broad guard would have regressed it. A 21-shape, two-locale table (LC_ALL=C and C.utf8, 42 measurements) passes 42/42 with the fix and fails 16 at 664dcac, all 16 inside the reviewed shapes and none in a control. Each new shape also runs through the entrypoint's own yaml_auth_state: five WRONG at 664dcac, five RIGHT with the fix, two sibling controls RIGHT at both. End to end over a staged home with the shipped conf and the reviewer's BOM line prepended: enable-auth.sh at 664dcac took the file from one authentication mapping to two and rewrote the operator's class to StandardAuthenticator silently; with the fix it stays at one mapping and keeps the class, and the identical tree without the mark is 1 -> 1 at both versions, so plain files behave exactly as before. test-docker-entrypoint.sh exits 0 with the six new assertions and exits 1 with only yamlscan.awk reverted to 664dcac, failing first on "byte order mark before the root key is not part of it: got none, want named"; the new CRLF block-scalar assertion sits inside the existing probe-gated CRLF group, which this host runs rather than skips. bash -n and gawk --lint clean, staged blobs byte-checked to 0 CR, the awk still holds no literal apostrophe. Not executed here, named rather than assumed: there is no Docker daemon and no Gremlin server process, so nothing bound a port -- Settings.read() is the loader, not the boot. Only gawk is installed, so mawk and BWK awk are not exercised; the BOM's byte form and code-point form are covered by the two locales above, which is the closest stand-in. The snakeyaml and gremlin jars were reused from the earlier local run of this harness, not re-fetched. The four host-gated assertion groups (CRLF byte check, config-mode preservation, symlinked config, symlinked temp file) still skip on Windows and run only under CI. CI has not run on this commit: all four workflows at 664dcac are completed/action_required, which means not run, and nothing here changes that. --- .../docker/test/test-docker-entrypoint.sh | 38 +++++++++ .../hugegraph-dist/docker/yamlscan.awk | 77 ++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) 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 e3569ffc4f..350c9a29a7 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -1336,6 +1336,32 @@ yaml_case nameless "authenticator value that is a nested mapping" \ 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. @@ -1719,6 +1745,18 @@ mkdir -p "${crlf_yaml_dir}/conf" 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}" diff --git a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk index a0e3e2fc44..107b94ca68 100644 --- a/hugegraph-server/hugegraph-dist/docker/yamlscan.awk +++ b/hugegraph-server/hugegraph-dist/docker/yamlscan.awk @@ -84,6 +84,24 @@ # 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 @@ -98,6 +116,19 @@ 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) { @@ -435,6 +466,20 @@ function unanchor(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 @@ -494,6 +539,7 @@ BEGIN { PENDING = 0 PENDING_IND = 0 EXPLICIT = 0 + ROOT_PROP = 0 NESTED_VAL = 0 } @@ -506,6 +552,12 @@ function finish_block() { 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) { @@ -567,6 +619,23 @@ function handle_line(raw, line, ind, v, t, f) { 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 @@ -694,7 +763,11 @@ function handle_line(raw, line, ind, v, t, f) { # 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. - seg_n = split($0, seg, /\r/) + # 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]) } @@ -709,6 +782,8 @@ END { 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" From 9ae26c8f74f7ae155a7b9092f961f538df91bd7c Mon Sep 17 00:00:00 2001 From: Adarsh Date: Tue, 29 Sep 2026 00:41:58 +0530 Subject: [PATCH 20/22] fix(docker): state get's real exit status, and drop the has mode nothing calls Two Minor findings from bitflicker64's 2026-09-28 review at 364d42f, both reproduced before anything was changed. 1. The header promised "PROPS_MODE=get ... Always exits 0." It does not: props_load dies for an unreadable file (props.awk:237) and for a file using a commons-configuration include directive (props.awk:288), both exit 2. Measured at 364d42f exactly as the reviewer described: PROPS_KEY=a against /nonexistent prints "props.awk: cannot read /nonexistent" and exits 2, and the same call on a file whose first line is "include=x.properties" also exits 2. The sentence now states the contract the callers actually depend on -- check_auth_sides (docker-entrypoint.sh:162-167) and props_get (enable-auth.sh:79-84) both treat a nonzero status as "the reader could not answer" and refuse, so a reader trusting the old wording could delete those guards and boot REST open beside a Gremlin that authenticates. 2. PROPS_MODE=has was dead code. Nothing outside the test suite called it: at 364d42f, grepping the whole hugegraph-dist tree finds has-mode invocations only in test-docker-entrypoint.sh, while docker-entrypoint.sh uses get and set and enable-auth.sh reads through props_get. Its own header text presented it as the right guard before appending a default -- the approach ensure_rest_prop moved away from for the reason recorded at enable-auth.sh:108-116 (an empty definition is present to a status check and absent to the value check). The mode, props_has and its tests are removed rather than kept "in case", and the two comments that named the function now name the check instead. The suite's has-mode block becomes three get-mode assertions, so the status contract corrected in (1) is executed rather than only documented: an empty definition reads back empty, an absent key in a readable file exits 0, and an unreadable file exits 2. The include-refusal assertion for has is dropped, not converted -- the read refusal one line above already covers it through get_prop_encoded, and the write refusal below covers the other half. Verified on this host (win32, Git Bash, gawk 5.0.0): - docker/test/test-docker-entrypoint.sh exits 0 before and after, output identical apart from nothing at all, with the same 4 host-gated skips (symlink / CR / chmod groups this host cannot exercise, which run under CI). - each new assertion shown to discriminate by mutating props.awk: die exit 2 -> 1 fails the unreadable case; get exiting 1 on absence fails the absent-key case; printing before an empty value fails the empty-definition case. Restored after each, no mutation committed. - has mode confirmed gone, not merely unreferenced: PROPS_MODE=has exits 0 at 364d42f and exits 2 with "PROPS_MODE must be get or set" here. - repo-wide grep for PROPS_MODE=has / props_has returns 0 files; bash -n clean on both scripts; gawk --lint warnings 15 -> 13 with no new kind; git diff --check clean; committed blobs verified CR-free. Not verified: CI. Every run on this head is still completed/action_required, so the Docker Build CI job that executes this suite has never run any of it -- the approval was asked for twice already on this PR, so it is not asked a third time here. The document change in (1) has no failing-before test by nature; what is tested is the status contract the corrected sentence describes. --- .../docker/test/test-docker-entrypoint.sh | 38 ++++++++----------- .../src/assembly/static/bin/enable-auth.sh | 6 ++- .../src/assembly/static/bin/props.awk | 34 ++++++----------- 3 files changed, 30 insertions(+), 48 deletions(-) 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 350c9a29a7..288f4274ec 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -1045,29 +1045,26 @@ printf '\f\f\ngraph=a\n' > "${ff_file}" [[ "$(get_prop_encoded 'graph' "${ff_file}")" == "a" ]] assert_line_count 1 '^graph=a$' "${ff_file}" -# has-mode answers "is this key defined" without confusing an empty definition -# with no definition, which is what an append guard needs: appending a default -# on top of `auth.authenticator=` leaves the empty first definition in force. -has_file="${test_dir}/config-has" -printf 'auth.authenticator=\n' > "${has_file}" -if ! PROPS_MODE=has PROPS_KEY='auth.authenticator' PROPS_FILE="${has_file}" \ - awk -f "${PROPS_AWK}" /dev/null; then - echo "PROPS_MODE=has must report an empty definition as present" >&2 +# 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 -fi -if PROPS_MODE=has PROPS_KEY='auth.graph_store' PROPS_FILE="${has_file}" \ - awk -f "${PROPS_AWK}" /dev/null; then - echo "PROPS_MODE=has must report an absent key as absent" >&2 +} +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 -# An unreadable file must not read as "absent": status 2 is what tells a caller -# wearing errexit to stop rather than append a default over a file it could not -# read. status=0 -PROPS_MODE=has PROPS_KEY='k' PROPS_FILE="${test_dir}/no-such-file" \ - awk -f "${PROPS_AWK}" /dev/null 2>/dev/null || status=$? +get_prop_encoded 'k' "${test_dir}/no-such-file" >/dev/null 2>&1 || status=$? if (( status != 2 )); then - echo "PROPS_MODE=has must exit 2 for an unreadable file, got ${status}" >&2 + echo "get must exit 2 for an unreadable file, got ${status}" >&2 exit 1 fi @@ -1791,11 +1788,6 @@ mkdir -p "${include_dir}/conf" echo "a read of a file with an include must refuse, not answer" >&2 exit 1 fi - if PROPS_MODE=has PROPS_KEY=restserver.url PROPS_FILE="${REST_SERVER_CONF}" \ - awk -f "${PROPS_AWK}" /dev/null 2>/dev/null; then - echo "PROPS_MODE=has must refuse too" >&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 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 e7d5e6d6bd..ec1b566c59 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 @@ -106,10 +106,12 @@ props_set() { } # Give `$3` its default `$2` for key `$1`, in place, unless it already has a -# value. Guarding with props_has and appending was not the same question: +# 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 props_has reported them as +# 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 diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk index c5687310a2..cc9b53cbc7 100644 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -25,10 +25,11 @@ # 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. Always exits 0. -# PROPS_MODE=has PROPS_KEY=K PROPS_FILE=F -# print nothing; exit 0 when K has any definition at all, empty -# included, 1 when it has none, 2 on an error +# 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 @@ -64,9 +65,9 @@ function die(msg) { printf "props.awk: %s\n", msg > "/dev/stderr" - # 2 for an error, so a caller that reads exit status 1 as "the key is not - # there" (PROPS_MODE=has) cannot mistake an unreadable file for an absent - # property and append a definition on top of one it failed to read. + # 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 } @@ -389,19 +390,8 @@ function props_get(file, key, decoded, b) { # 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. PROPS_MODE=has is the mode that reports by status. -} - -# Exit status only: 0 when the key has any definition at all, including an -# empty one. Guards that append a default must not treat `auth.authenticator=` -# as absent, because appending a second definition leaves the empty first one -# in force under first-definition-wins. -function props_has(file, key, b) { - props_load(file) - for (b = 1; b <= NBLOCK; b++) { - if (BTYPE[b] == "entry" && BKEY[b] == key) return 0 - } - return 1 + # 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 { @@ -413,11 +403,9 @@ BEGIN { die("PROPS_FILE and PROPS_KEY must be set") if (mode == "get") { props_get(file, key, decoded) - } else if (mode == "has") { - if (props_has(file, key)) exit 1 } else if (mode == "set") { props_set(file, key, ENVIRON["PROPS_VALUE_ENCODED"]) } else { - die("PROPS_MODE must be get, has or set") + die("PROPS_MODE must be get or set") } } From f61a0f078b4756675c417d2268962301aac35fb9 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Tue, 29 Sep 2026 16:52:56 +0530 Subject: [PATCH 21/22] fix(docker): read every value the script decides on before the first write bitflicker64's 2026-09-29 review at 9ae26c8: the yaml authentication block is appended before anything reads rest-server.properties, so a file props.awk refuses leaves exactly the one-sided tree the comment above append_lines says this script must not leave. Reproduced at 9ae26c8 on this host before touching anything (win32, Git Bash, gawk 5.0.0), tarball layout over a conf/ copy with `include=other.properties` in rest-server.properties: props.awk: refusing to read or rewrite .../rest-server.properties: it uses an include directive (line 2), ... enable-auth.sh: cannot read .../rest-server.properties props.awk: refusing to read or rewrite .../rest-server.properties: ... enable-auth.sh: cannot update .../rest-server.properties (exit 1) gremlin-server.yaml gains the block naming StandardAuthenticator rest-server.properties no auth.authenticator The early read did not catch it, and the reason is the shape of the guard: ensure_rest_prop tested `[[ -n "$(props_get "$1" "$3")" ]]`, and `[[` keeps the text of a command substitution while discarding its status, so props_get's fail only exited the subshell. graphs/hugegraph.properties was worse in kind because it was read last of all: an include directive there let the run write both auth sides and leave gremlin.graph=org.apache.hugegraph.HugeFactory unwrapped, then exit 1. Neither case self-repairs in the release tarball -- once the block is in the yaml, a rerun after the operator removes the include line stops on the unverifiable branch, which is what the review reported. All three values the decisions below act on (auth.authenticator, auth.graph_store, gremlin.graph) are now read before the first write, as plain assignments so errexit sees the nonzero status props.awk returns, and ensure_rest_prop takes the value it guards on instead of re-reading inside a test that threw the status away. The reads sit after the nameless refusal so that branch keeps its own message. props.awk's header needed no edit: its claim that props_get relies on get's nonzero status (props.awk:30-32) was the correct contract, and the caller was the thing that did not honour it. test-docker-entrypoint.sh gains refused_read over both layouts x both unreadable files: the refused run must leave all three configs byte-identical to a snapshot taken before it ran, and one run after the operator's repair has to arm both sides and wrap the factory. Verified on this host: - All four permutations discriminate. At 9ae26c8 each leaves a one-sided tree (image/tarball x rest/graphs: yaml block appended; for graphs also both REST keys written); with the fix each writes nothing. Reverting only enable-auth.sh and keeping the test makes the suite exit 1 on "a refused read still edited gremlin-server.yaml". - docker/test/test-docker-entrypoint.sh exits 0 with the fix, with the same 4 host-gated skips as the baseline at 9ae26c8, and its output differs from baseline by 8 added stderr lines (the four new refusals) and none removed. - docker/docker-entrypoint-test.sh exits 0 with the fix. - Branches that were already right stay identical head vs fix, both layouts: the unverifiable refusal, an operator class already on REST, a named mapping, and a nameless refusal give the same exit status, same message, same files. - bash -n clean on both scripts, git diff --check clean, staged blobs CR-free (CI checks out LF; this worktree was created with core.autocrlf=false). Not verified: CI. All six runs at 9ae26c8 are still completed/action_required, so nothing in either suite has executed under CI on any head of this PR, and approval is per head sha, so this push holds again. Not asked here: this PR already carries the workflow-approval ask twice (2026-09-23T16:30:23Z, 16:34:20Z). --- .../docker/test/test-docker-entrypoint.sh | 99 +++++++++++++++++++ .../src/assembly/static/bin/enable-auth.sh | 35 +++++-- 2 files changed, 128 insertions(+), 6 deletions(-) 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 288f4274ec..2e59a70b8b 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -1163,6 +1163,105 @@ printf '%s\n' 'gremlin.graph=org.apache.hugegraph.HugeFactory' \ 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 + # ── 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 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 ec1b566c59..f26811c312 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 @@ -120,8 +120,15 @@ props_set() { # 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 "$(props_get "$1" "$3")" ]] && return 0 + [[ -n "$4" ]] && return 0 props_set "$1" "$2" "$3" } @@ -231,8 +238,23 @@ 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 -if [[ "${GREMLIN_AUTH}" == "unverifiable" ]] && - [[ -z "$(props_get "auth.authenticator" "${CONF}/${REST_SERVER_CONF}")" ]]; then +# 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}") + +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. @@ -254,8 +276,10 @@ if [[ "${GREMLIN_AUTH}" == "none" ]]; then '}' fi -ensure_rest_prop "auth.authenticator" "${AUTHENTICATOR_CLASS}" "${CONF}/${REST_SERVER_CONF}" -ensure_rest_prop "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}" # 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. @@ -269,7 +293,6 @@ ensure_rest_prop "auth.graph_store" "hugegraph" "${CONF}/${REST_SERVER_CONF}" # 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. -GRAPH_FACTORY=$(props_get "gremlin.graph" "${CONF}/graphs/${GRAPH_CONF}") while [[ "${GRAPH_FACTORY}" =~ [[:space:]]$ ]]; do GRAPH_FACTORY="${GRAPH_FACTORY%?}" done From 0be7fde77c593d2aeb6954adee9ac007d474e30c Mon Sep 17 00:00:00 2001 From: Adarsh Date: Wed, 30 Sep 2026 01:02:18 +0530 Subject: [PATCH 22/22] fix(docker): refuse a config the run cannot write, and do not call it damaged Two inline comments from bitflicker64 at 2026-09-29T18:44:46Z on f61a0f0, both about a destination that can be read but not written -- the case f61a0f0 moved the reads in front of, but could still reach through the write itself. 1. props.awk:375 called an untouched config damaged. The copy-back was the first write attempted, and the shell opens the destination for writing before cat writes a byte, so EACCES truncates nothing: props.awk: cannot copy D/rest-server.properties.tmp.0hAsd8 over D/rest-server.properties; D/rest-server.properties is damaged, previous content is in D/rest-server.properties.bak.jc7JZd (exit 2) three attempts -> 3 x .tmp.* + 3 x .bak.*, every tmp holds the value Reproduced here at f61a0f0 over a chmod 444 copy with PROPS_MODE=set PROPS_KEY=auth.admin_pa PROPS_VALUE_ENCODED=s3cretVALUE, cmp against the original identical, so "damaged" is false and a container on a restart policy leaks one secret-bearing temp file per restart. 2. enable-auth.sh:271 still left the one-sided tree: append_lines guards only the yaml it appends to, so a read-only rest-server.properties gave the yaml the authentication block, props_set failed on the REST side, exit 1. Measured on all four permutations (2 layouts x 2 files) at f61a0f0: every one exits 1 with the tree half-written and 2 staging files left in conf/, and the tarball rest case does not repair itself -- after chmod u+w the rerun still exits 1 on the unverifiable branch, exactly as the review reported. That the second repro runs here at all is worth recording: host_keeps_chmod is 0 on this host because stat reports 644 for a 0600 file, and every earlier run read that as "chmod is a no-op". Write denial is a separate question from mode reporting. Measured: after chmod 444, test -w is false, `>` and `>>` are denied, reads are unaffected, and gawk's `>` fails too. So a read-only config is a real observable here, and these regressions could be executed rather than argued. props.awk checks the destination is writable before make_temp runs, so nothing is staged and no secret is left behind, and on a copy-back failure it compares the file against the snapshot before accusing it: intact means remove both staged files and say nothing was written. The compare is not redundant with the check, because root writes a 0444 file and access() reports a bind-mounted read-only file as writable -- so the check is the fast path and the compare is the backstop. Reading a read-only config still works, which the new test pins: refusing to write must not turn into refusing to boot. enable-auth.sh gates both writes before the yaml append, and only where this run actually owes a write: rest-server.properties when it answers neither auth.authenticator nor auth.graph_store, graphs/hugegraph.properties when the trimmed gremlin.graph is the plain HugeFactory. The trim moved up to sit with the reads so the guard and the write cannot disagree about the same value; root can write a 0444 file, so this asks -w rather than reading the mode. Tests: a host_denies_write probe (tries the write, since gating on host_keeps_chmod would skip this group where it genuinely runs, and gating on nothing would fail on a host that ignores modes); props.awk set x3 over a read-only config asserting the message, a byte-identical file and no .tmp.*/.bak.* left; the compare branch through a cat that fails without writing, on an empty config, which is the shape where that genuinely leaves the original intact; and refused_write over 2 layouts x 2 files asserting nothing edited, nothing staged left behind, and one run after the repair arming both sides and wrapping the factory. Executed: full suite exit 0 with the fix (1m56s) and exit 0 at f61a0f0 without the new cases, same 4 host-gated skips both ways, and the normalised output diff is 0 lines removed with only the 4 new refusal messages added. Discrimination: head + new tests exits 1, and driven per permutation (the suite stops at its first failed assertion) all 6 new cases fail at f61a0f0 and pass with the fix. docker-entrypoint-test.sh, which uses props.awk but stubs enable-auth.sh, exits 0 at both. bash -n clean, CR=0 on all three committed blobs, index blobs byte-identical to the files that were run. Not verified, and not claimed: CI still has never executed this account's code. The six runs at f61a0f0 are completed/action_required and a push re-holds them per head sha; the approval ask on this PR already exists twice (2026-09-23), so it is not repeated here. Under a root CI user chmod 444 does not deny writes, so the two new groups print their skip line rather than passing quietly. A real bind-mounted read-only file cannot be created on this host, so the backstop branch is exercised through the failing cat, not through a mount. --- .../docker/test/test-docker-entrypoint.sh | 213 ++++++++++++++++++ .../src/assembly/static/bin/enable-auth.sh | 58 +++-- .../src/assembly/static/bin/props.awk | 34 ++- 3 files changed, 286 insertions(+), 19 deletions(-) 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 2e59a70b8b..c59ad10f37 100644 --- a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh @@ -85,6 +85,27 @@ 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 @@ -928,6 +949,93 @@ cmp -s "${rb_bak}" "${rb_expect}" || { 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 @@ -1262,6 +1370,111 @@ for scan in yes no-yamlscan; do 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 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 f26811c312..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 @@ -254,6 +254,47 @@ 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}") +# 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 @@ -281,21 +322,8 @@ ensure_rest_prop "auth.authenticator" "${AUTHENTICATOR_CLASS}" \ ensure_rest_prop "auth.graph_store" "hugegraph" \ "${CONF}/${REST_SERVER_CONF}" "${REST_GRAPH_STORE}" -# 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. -while [[ "${GRAPH_FACTORY}" =~ [[:space:]]$ ]]; do - GRAPH_FACTORY="${GRAPH_FACTORY%?}" -done +# 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}" diff --git a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk index cc9b53cbc7..22049aa8d0 100644 --- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk +++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/props.awk @@ -36,7 +36,9 @@ # 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. +# 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): @@ -314,6 +316,19 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs, 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) { @@ -366,10 +381,21 @@ function props_set(file, key, enc_val, tmp, bak, cmd, b, first, ln, msg, nbs, die("cannot back up " file " before the copy-back") cmd = "cat -- " shquote(tmp) " > " shquote(file) if (system(cmd) != 0) { - # Best effort: the destination is already 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. 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)