From fb05bbf89eda034e819b4812910115c5fd64b926 Mon Sep 17 00:00:00 2001 From: Matthew Date: Wed, 28 Oct 2026 12:13:54 +1000 Subject: [PATCH 1/2] Check extensions without starting the application rustcfml-warm-extensions ran `rustcfml -c 'writeOutput("")'` in the webroot. Inline code there runs the app's Application.cfc, so every container start booted the application once before the server: onApplicationStart, then onRequest with no page, which an onRequest that includes its target page turns into an error. The probe is now an empty template in a scratch directory, with the webroot's extensions/ linked in so the search order is unchanged, and the cwd still the webroot so the server .cfconfig.json is found as before. Messages report the webroot's paths. A smoke case checks that the check does not fire onApplicationStart. --- docker/warm-extensions.sh | 19 ++++++++++++++++--- tests/smoke.sh | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docker/warm-extensions.sh b/docker/warm-extensions.sh index e840fe9..ae34919 100755 --- a/docker/warm-extensions.sh +++ b/docker/warm-extensions.sh @@ -37,12 +37,25 @@ if [ "$found" -eq 0 ] && [ -z "${CFCONFIG:-}" ] && [ ! -f "$WEBROOT/.cfconfig.js exit 0 fi -set -- --verbose -c 'writeOutput("")' +# The probe is an empty template in a scratch directory, not inline code run in +# the webroot: inline code there runs the app's Application.cfc (onApplicationStart, +# then onRequest with no page), so every container start would boot the app once +# against its database and could log an error. The engine's application directory +# is the template's, so the webroot's extensions/ is linked into the scratch +# directory to keep it in the search; the cwd stays the webroot, so the server +# .cfconfig.json (and its extensions.directory) is found exactly as the server finds it. +probe="$(mktemp -d)" +trap 'rm -rf "$probe"' EXIT +: > "$probe/probe.cfm" +[ -d "$WEBROOT/extensions" ] && ln -s "$WEBROOT/extensions" "$probe/extensions" + +set -- --verbose "$probe/probe.cfm" [ -n "${RUSTCFML_EXTENSIONS:-}" ] && set -- --extensions "$RUSTCFML_EXTENSIONS" "$@" # One engine start in CLI mode walks the exact loader the server uses; verbose -# prints "Loaded extension …" per success and "…warning…" per problem. -out="$(cd "$WEBROOT" && rustcfml "$@" 2>&1 | grep -v '^\[.*\] DEBUG ' || true)" +# prints "Loaded extension …" per success and "…warning…" per problem (with the +# scratch link's paths reported as the webroot's). +out="$(cd "$WEBROOT" && rustcfml "$@" 2>&1 | grep -v '^\[.*\] DEBUG ' | sed "s|$probe/extensions/|$WEBROOT/extensions/|g" || true)" loaded="$(printf '%s\n' "$out" | grep -c '^Loaded extension ' || true)" problems="$(printf '%s\n' "$out" | grep -i -c 'warning' || true)" diff --git a/tests/smoke.sh b/tests/smoke.sh index c0105fa..cc3ca14 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -104,6 +104,22 @@ curl -s "http://127.0.0.1:$PORT/index.cfm" | grep -q "Hello from RustCFML" || fa docker rm -f "$cid" >/dev/null; trap - EXIT rm -rf "$tmp" +echo "--- the extension check does not start the application" +# The check runs before the server, at every container start. Booting the app's +# Application.cfc there would open its database and fire its handlers with no page. +tmp=$(mktemp -d) +echo '{}' > "$tmp/.cfconfig.json" +cat > "$tmp/Application.cfc" <<'CFC' + + + + +CFC +docker run --rm --entrypoint sh -v "$tmp:/srv/app:ro" "$IMAGE" \ + -c 'rustcfml-warm-extensions && test ! -e /tmp/app-started' \ + || fail "the extension check ran the application's onApplicationStart" +rm -rf "$tmp" + if [ -n "${SMOKE_RCX:-}" ]; then echo "--- real extension: $SMOKE_RCX" tmp=$(mktemp -d); mkdir -p "$tmp/extensions"; cp "$SMOKE_RCX" "$tmp/extensions/" From 2af48c1ea670eaedf5a55584ebed4499075dc997 Mon Sep 17 00:00:00 2001 From: Matthew Date: Wed, 28 Oct 2026 12:38:44 +1000 Subject: [PATCH 2/2] Extension check: stop the upward search, absolute webroot, literal path rewrite, read-only fallback - An empty Application.cfc beside the probe ends the engine's upward search, so the app's Application.cfc is not run when the scratch directory is under the webroot (TMPDIR inside it, or a webroot of /tmp). - The webroot is made absolute before it is linked into the scratch directory; a relative one (RUSTCFML_WEBROOT=.) linked to itself and skipped the webroot's extensions, so a broken one passed. - Problems are counted from the engine's unedited output, and the shown paths are rewritten with a literal awk replacement. A | in the webroot broke the sed expression, emptied the output and let a broken extension pass; a & garbled the shown paths. - With no writable temp directory (a read-only container) the check falls back to the previous inline probe in the webroot instead of failing. A smoke case for each; all four fail against the previous version. Co-Authored-By: Claude Opus 5.5 --- docker/warm-extensions.sh | 62 ++++++++++++++++++++++++++++++--------- tests/smoke.sh | 37 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docker/warm-extensions.sh b/docker/warm-extensions.sh index ae34919..2ca2cc4 100755 --- a/docker/warm-extensions.sh +++ b/docker/warm-extensions.sh @@ -20,6 +20,11 @@ set -eu WEBROOT="$(/usr/local/bin/rustcfml-webroot)" +# Absolute, so the link into the probe's scratch directory (below) points at it +# from anywhere, not relative to the scratch directory. +if [ -d "$WEBROOT" ]; then + WEBROOT="$(cd "$WEBROOT" && pwd)" +fi STRICT="${RUSTCFML_EXTENSIONS_STRICT:-1}" BIN_DIR="$(dirname "$(readlink -f "$(command -v rustcfml)")")" @@ -40,29 +45,58 @@ fi # The probe is an empty template in a scratch directory, not inline code run in # the webroot: inline code there runs the app's Application.cfc (onApplicationStart, # then onRequest with no page), so every container start would boot the app once -# against its database and could log an error. The engine's application directory -# is the template's, so the webroot's extensions/ is linked into the scratch -# directory to keep it in the search; the cwd stays the webroot, so the server -# .cfconfig.json (and its extensions.directory) is found exactly as the server finds it. -probe="$(mktemp -d)" -trap 'rm -rf "$probe"' EXIT -: > "$probe/probe.cfm" -[ -d "$WEBROOT/extensions" ] && ln -s "$WEBROOT/extensions" "$probe/extensions" - -set -- --verbose "$probe/probe.cfm" +# against its database and could log an error. +# +# - An empty Application.cfc beside the probe ends the engine's upward search +# for one, so the app's is not found even when the scratch directory is under +# the webroot (TMPDIR inside it, or a webroot of /tmp). +# - The engine's application directory is the template's, so the webroot's +# extensions/ is linked into the scratch directory to keep it in the search. +# - The cwd stays the webroot, so the server .cfconfig.json (and its +# extensions.directory) is found exactly as the server finds it. +# +# With no writable temp directory (a read-only container), fall back to the +# inline probe in the webroot, which runs the app's Application.cfc as before. +probe="" +if probe="$(mktemp -d 2>/dev/null)"; then + trap 'rm -rf "$probe"' EXIT + : > "$probe/probe.cfm" + printf '\n' > "$probe/Application.cfc" + [ -d "$WEBROOT/extensions" ] && ln -s "$WEBROOT/extensions" "$probe/extensions" + set -- --verbose "$probe/probe.cfm" +else + probe="" + echo "rustcfml: no writable temp directory; checking extensions from the webroot, which runs its Application.cfc" >&2 + set -- --verbose -c 'writeOutput("")' +fi [ -n "${RUSTCFML_EXTENSIONS:-}" ] && set -- --extensions "$RUSTCFML_EXTENSIONS" "$@" # One engine start in CLI mode walks the exact loader the server uses; verbose -# prints "Loaded extension …" per success and "…warning…" per problem (with the -# scratch link's paths reported as the webroot's). -out="$(cd "$WEBROOT" && rustcfml "$@" 2>&1 | grep -v '^\[.*\] DEBUG ' | sed "s|$probe/extensions/|$WEBROOT/extensions/|g" || true)" +# prints "Loaded extension …" per success and "…warning…" per problem. Counts +# come from the engine's own output; only the lines shown are edited. +out="$(cd "$WEBROOT" && rustcfml "$@" 2>&1 | grep -v '^\[.*\] DEBUG ' || true)" loaded="$(printf '%s\n' "$out" | grep -c '^Loaded extension ' || true)" problems="$(printf '%s\n' "$out" | grep -i -c 'warning' || true)" +# Report the scratch link's paths as the webroot's. A literal replacement (awk +# index), so no character in either path can change what is matched. +show() { + FROM="${probe:+$probe/extensions/}" TO="$WEBROOT/extensions/" awk ' + BEGIN { f = ENVIRON["FROM"]; t = ENVIRON["TO"] } + { + rest = $0; done = "" + if (f != "") while ((i = index(rest, f)) > 0) { + done = done substr(rest, 1, i - 1) t + rest = substr(rest, i + length(f)) + } + print " " done rest + }' +} + if [ "$found" -gt 0 ] || [ "$loaded" -gt 0 ] || [ "$problems" -gt 0 ]; then echo "rustcfml: extensions — $found .rcx found, $loaded loaded, $problems problem(s)" - printf '%s\n' "$out" | grep -E '^Loaded extension |[Ww]arning' | sed 's/^/ /' || true + printf '%s\n' "$out" | grep -E '^Loaded extension |[Ww]arning' | show || true fi if [ "$problems" -gt 0 ] && [ "$STRICT" = 1 ]; then diff --git a/tests/smoke.sh b/tests/smoke.sh index cc3ca14..db611c3 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -120,6 +120,43 @@ docker run --rm --entrypoint sh -v "$tmp:/srv/app:ro" "$IMAGE" \ || fail "the extension check ran the application's onApplicationStart" rm -rf "$tmp" +echo "--- ... nor when its scratch directory is inside the webroot" +# The probe's own empty Application.cfc stops the engine's upward search there. +tmp=$(mktemp -d); mkdir -m 0777 "$tmp/scratch" +echo '{}' > "$tmp/.cfconfig.json" +cat > "$tmp/Application.cfc" <<'CFC' + + + + +CFC +docker run --rm --entrypoint sh -v "$tmp:/srv/app" -e TMPDIR=/srv/app/scratch "$IMAGE" \ + -c 'rustcfml-warm-extensions && test ! -e /tmp/app-started' \ + || fail "with TMPDIR inside the webroot, the extension check ran onApplicationStart" +rm -rf "$tmp" + +echo "--- a broken extension is still fatal from a relative webroot, or one with | and & in its path" +tmp=$(mktemp -d); mkdir -p "$tmp/extensions"; echo junk > "$tmp/extensions/bad-0.0.1.rcx" +set +e +docker run --rm --entrypoint sh -v "$tmp:/srv/app:ro" -w /srv/app -e RUSTCFML_WEBROOT=. "$IMAGE" \ + -c 'rustcfml-warm-extensions' >/dev/null 2>&1; rc=$? +set -e +[ "$rc" -eq 78 ] || fail "relative webroot: expected exit 78, got $rc" +set +e +out=$(docker run --rm --entrypoint sh -v "$tmp:/srv/a|b&c:ro" -e "RUSTCFML_WEBROOT=/srv/a|b&c" "$IMAGE" \ + -c 'rustcfml-warm-extensions' 2>&1); rc=$? +set -e +echo "$out" +[ "$rc" -eq 78 ] || fail "webroot with | and &: expected exit 78, got $rc" +echo "$out" | grep -qF "/srv/a|b&c/extensions/bad-0.0.1.rcx" || fail "webroot with | and &: path not reported as the webroot's" +rm -rf "$tmp" + +echo "--- a read-only container still passes the extension check" +tmp=$(mktemp -d); echo '{}' > "$tmp/.cfconfig.json" +docker run --rm --read-only --entrypoint sh -v "$tmp:/srv/app:ro" "$IMAGE" -c 'rustcfml-warm-extensions' \ + || fail "the extension check failed in a read-only container" +rm -rf "$tmp" + if [ -n "${SMOKE_RCX:-}" ]; then echo "--- real extension: $SMOKE_RCX" tmp=$(mktemp -d); mkdir -p "$tmp/extensions"; cp "$SMOKE_RCX" "$tmp/extensions/"