thermograph/infra/deploy/render-secrets-openbao.sh
Emi Griffith 657e27ba9f
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 5s
secrets-guard / encrypted (push) Successful in 4s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 1s
Sync infra to hosts / sync-prod (push) Has been skipped
shell-lint / shellcheck (push) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / changes (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 7s
openbao: render Centralis from the vault
Centralis could not migrate at all. `seed-from-sops.sh --all` refused
centralis.prod with `CENTRALIS_TOKENS: contains a shell metacharacter`, and
render-secrets-openbao.sh had no Centralis path whatsoever — so the seeded data
would have had nothing to read it.

Both halves come from applying the app stack's rules to a file that does not share
its shape. /etc/thermograph.env is raw unquoted KEY=value. /etc/centralis.env is
consumed by exactly one thing and that thing is a shell:

    sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up'

CENTRALIS_TOKENS is JSON, so it can never satisfy a no-metacharacters rule, and
rendering it unquoted is precisely the 2026-07-24 failure: bash strips the quotes,
Centralis fails closed on the malformed registry and serves a single `shared`
identity, indistinguishable from the file never being written.

So:

- seed-from-sops.sh validates against the renderer that will consume the path. The
  dotenv rules still apply to common and env/*; centralis/* is exempt because it is
  single-quoted downstream.
- render-secrets-openbao.sh gains thermograph_render_openbao_centralis, mirroring
  render_centralis_secrets: fetch as JSON, emit POSIX single-quoted assignments,
  then PROVE the file by sourcing it under `env -i` with `set -a` and comparing
  every value back before anything touches the destination. Mirrored rather than
  shared, per the existing note above thermograph_render_openbao — consolidate when
  the SOPS path is deleted.
- render_centralis_secrets gains the same early-return backend dispatch the app
  path already uses, so the new function is reachable. Without it the SOPS path was
  the only path regardless of TG_SECRETS_BACKEND.

Verified against a synthetic registry containing a JSON token map, an apostrophe,
and `$(...)`/backtick/backslash/double-quote payloads: 5 keys round-trip
byte-for-byte through `set -a; . file`, CENTRALIS_TOKENS parses to the right
subjects, and the command substitution does not execute. The negative case is
covered too — fed the unquoted 2026-07-24 shape, the round-trip check reports the
value as changed (by length, never content) and refuses to write.

Also corrects the claim in render-secrets-openbao.sh that the SOPS path never
tripped the metacharacter hazard "because every current value happens to be
alphanumeric". CENTRALIS_TOKENS is the counter-example; it never tripped those
paths because it was never in them.
2026-07-30 19:55:17 -07:00

493 lines
21 KiB
Bash

#!/usr/bin/env bash
# OpenBao source backend for render-secrets.sh. Sourced, never run directly.
#
# Functions:
# thermograph_openbao_source <env> -> merged dotenv on STDOUT
# thermograph_render_openbao <env> <out> -> source, then write <out>
# thermograph_render_openbao_centralis <env> [out] -> quoted /etc/centralis.env
#
# The first two serve the app stack (/etc/thermograph.env, raw unquoted dotenv). The
# third serves Centralis, whose file is SOURCED by bash and therefore single-quoted —
# a different shape for a different consumer, not an inconsistency.
#
# render_thermograph_secrets in render-secrets.sh dispatches to the second when
# TG_SECRETS_BACKEND=openbao, and is otherwise completely untouched — so the SOPS path
# keeps byte-identical behaviour and all new risk is confined to this file. See the
# note above thermograph_render_openbao on why the write path is duplicated here
# rather than shared, and when to consolidate.
#
# WHY OUTPUT SHAPE IS FROZEN: /etc/thermograph.env must stay raw, UNQUOTED KEY=value.
# Centralis' secrets_inventory / secrets_render compare that file textually
# (deploy/secrets/README.md:206-210), compose reads it via `env_file:`, and the Swarm
# stack's env-entrypoint.sh parses it line by line. Quoting it "properly" would be an
# improvement in isolation and a silent break in context.
# thermograph_openbao_source <env_name>
#
# Reads the common set then the per-environment set and merges them with the
# environment winning — the same last-wins semantic as the SOPS concatenation, except
# performed explicitly here because OpenBao returns a map per path rather than a
# stream that can simply be appended.
thermograph_openbao_source() {
local env_name="${1:?env name required}"
local addr="${THERMOGRAPH_BAO_ADDR:-https://10.10.0.1:8200}"
local mount="${THERMOGRAPH_BAO_MOUNT:-thermograph}"
# Explicit override first, then the per-environment value env-topology.sh derives,
# then the conventional path. Same precedence shape as render-secrets.sh:44's
# THERMOGRAPH_SECRETS_BACKEND / TG_SECRETS_BACKEND pair, for the same reason: the
# THERMOGRAPH_-prefixed name is the by-hand escape hatch, the TG_ one is what the
# deploy path sets. Falling straight through to the bare default is what made beta
# authenticate as tg-prod and get denied its own path.
local approle="${THERMOGRAPH_BAO_APPROLE:-${TG_BAO_APPROLE:-/etc/thermograph/openbao-approle}}"
local ca="${THERMOGRAPH_BAO_CACERT:-/etc/thermograph/openbao-ca.crt}"
command -v bao >/dev/null 2>&1 || {
echo "!! bao CLI not installed but this host is configured for the openbao backend" >&2
return 1
}
command -v python3 >/dev/null 2>&1 || {
echo "!! python3 required to render dotenv from OpenBao JSON" >&2
return 1
}
# AppRole credentials: role_id and secret_id, one per line, 0400 root — the same
# protection level as /etc/thermograph/age.key today. Read directly if we can, else
# via sudo, mirroring how render-secrets.sh lifts the age key.
local creds
if [ -r "$approle" ]; then
creds=$(cat "$approle")
else
creds=$(sudo cat "$approle" 2>/dev/null || true)
fi
[ -n "$creds" ] || {
echo "!! cannot read OpenBao AppRole credentials at $approle (need sudo)" >&2
return 1
}
local role_id secret_id
role_id=$(printf '%s\n' "$creds" | sed -n '1p')
secret_id=$(printf '%s\n' "$creds" | sed -n '2p')
[ -n "$role_id" ] && [ -n "$secret_id" ] || {
echo "!! $approle malformed: expected role_id on line 1, secret_id on line 2" >&2
return 1
}
export BAO_ADDR="$addr"
[ -f "$ca" ] && export BAO_CACERT="$ca"
# Exchange the AppRole for a short-lived token. The token TTL is set on the role
# (see bootstrap.sh) and is deliberately measured in minutes: it only has to
# outlive one render.
local token
token=$(bao write -field=token auth/approle/login \
role_id="$role_id" secret_id="$secret_id" 2>/dev/null) || {
echo "!! OpenBao AppRole login failed for env='${env_name}'" >&2
echo "!! check that OpenBao at $addr is reachable and UNSEALED" >&2
return 1
}
export BAO_TOKEN="$token"
# Fetch. `common` is skipped for dev by the same flag the SOPS path uses — but note
# that on the OpenBao path the flag is belt, not braces: dev's AppRole is bound to
# the tg-host-dev policy, which DENIES thermograph/data/common outright. If this
# flag is ever forgotten at a call site, dev gets a 403 rather than a file full of
# production credentials. That inversion — from "the caller must remember" to "the
# store refuses" — is the main reason for this migration.
local common_json="" env_json
if [ "${THERMOGRAPH_SECRETS_SKIP_COMMON:-0}" != 1 ]; then
common_json=$(bao kv get -format=json -mount="$mount" common 2>/dev/null) || {
echo "!! cannot read ${mount}/common for env='${env_name}'" >&2
echo "!! if this is dev, THERMOGRAPH_SECRETS_SKIP_COMMON=1 was not set and the" >&2
echo "!! tg-host-dev policy correctly refused — that is the guard working." >&2
return 1
}
fi
env_json=$(bao kv get -format=json -mount="$mount" "env/${env_name}" 2>/dev/null) || {
echo "!! cannot read ${mount}/env/${env_name}" >&2
return 1
}
# Merge and emit. Python rather than jq because jq is not installed on these hosts
# and python3 is (the Centralis renderer already relies on it).
#
# This step also enforces the two invariants the dotenv format cannot express:
# * a value containing a newline is REJECTED, because it would silently become
# two lines and the second would parse as a bogus KEY=value or be dropped;
# * a value containing a shell metacharacter is REJECTED, because
# /etc/thermograph.env is sourced by bash in six places (deploy.sh:133,
# deploy-stack.sh:98, both daemon entrypoints, ops-cron.yml:85, terraform), so
# `$(...)` or a backtick in a secret is a live command-execution path on the
# deploy host. Refusing here turns a latent code-execution bug into a loud
# render failure.
#
# An earlier version of this comment claimed the SOPS path had never tripped
# this "only because every current value happens to be alphanumeric". That is
# false: CENTRALIS_TOKENS is JSON and full of double quotes. It has never
# tripped THESE paths because it is not in them — it lives at
# centralis/<env> and renders to /etc/centralis.env, which is single-quoted
# precisely because raw dotenv cannot carry it. See
# thermograph_render_openbao_centralis at the end of this file.
THERMOGRAPH_BAO_COMMON="$common_json" \
THERMOGRAPH_BAO_ENV="$env_json" \
python3 - <<'PY'
import json, os, re, sys
def load(raw):
if not raw:
return {}
doc = json.loads(raw)
return doc.get("data", {}).get("data", {}) or {}
merged = {}
# common first, environment second: last-wins, matching the SOPS concatenation order.
for src in ("THERMOGRAPH_BAO_COMMON", "THERMOGRAPH_BAO_ENV"):
merged.update(load(os.environ.get(src, "")))
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Characters that change meaning when the file is sourced by bash.
UNSAFE = set("`$\\\"'")
bad = []
lines = []
# Validate EVERYTHING before emitting a single byte. Printing as we go would leave
# partial output on stdout when a later key fails, and render-secrets.sh's whole
# discipline is that a failed source never produces a partial env file.
for key in sorted(merged):
if not KEY_RE.match(key):
bad.append(f"{key}: not a valid env var name")
continue
val = merged[key]
if val is None:
val = ""
if not isinstance(val, str):
val = json.dumps(val, separators=(",", ":"))
if "\n" in val or "\r" in val:
bad.append(f"{key}: value contains a newline (would corrupt the dotenv)")
continue
if UNSAFE & set(val):
bad.append(
f"{key}: value contains a shell metacharacter (one of ` $ \\ \" ') and "
f"/etc/thermograph.env is sourced by bash — refusing"
)
continue
lines.append(f"{key}={val}")
if bad:
sys.stderr.write("!! refusing to render; unsafe values:\n")
for line in bad:
sys.stderr.write(f"!! {line}\n")
sys.exit(1)
if not lines:
sys.stderr.write("!! refusing to render: OpenBao returned zero keys\n")
sys.exit(1)
sys.stdout.write("\n".join(lines) + "\n")
PY
}
# thermograph_render_openbao <env_name> <out_file>
#
# The OpenBao equivalent of render_thermograph_secrets: source, then write.
#
# ON THE DUPLICATED WRITE PATH. The write logic below mirrors
# render-secrets.sh:130-154 rather than being factored out and shared. That is a
# deliberate, temporary choice, not an oversight:
#
# render-secrets.sh is on the deploy path for all three environments and there is no
# test suite in front of it (infra/CLAUDE.md: "Shell here runs as root over SSH
# against live hosts with no test suite in front of it"). Extracting the write path
# would mean the SOPS render — which currently works — starts flowing through newly
# moved code that cannot be tested end-to-end from here. An early-return branch keeps
# the SOPS path byte-identical and confines all new risk to the new backend.
#
# CONSOLIDATE THESE after prod has been on the OpenBao backend for a full release
# cycle and the SOPS path is being deleted anyway. Until then, a change to one write
# path must be made in both — which is exactly the cost being accepted here.
thermograph_render_openbao() {
local env_name="${1:?env name required}"
local out="${2:?output path required}"
echo "==> Rendering $out from OpenBao (env=${env_name})"
local tmp; tmp=$(mktemp)
# Holds DECRYPTED secrets. Removed on every exit path. Deliberately not a
# `trap ... RETURN` — see render-secrets.sh:82-91 for why that is actively wrong in
# a sourced function (the trap persists into the caller and re-fires on its next
# `source`, where the local is unset and `set -u` makes it fatal and silent).
if ! thermograph_openbao_source "$env_name" > "$tmp"; then
rm -f "$tmp"
return 1
fi
# An empty render must never reach $out. The source function already refuses a
# zero-key result, so this is a second line of defence rather than the only one:
# a truncated /etc/thermograph.env is the single worst outcome available here,
# because thermograph.service uses `EnvironmentFile=-` (missing is NOT fatal) and
# the app self-generates AUTH_SECRET and the VAPID pair when they are absent. The
# result would be a green deploy that silently invalidated every session and
# deleted every push subscription.
if [ ! -s "$tmp" ]; then
echo "!! OpenBao render produced an empty file; refusing to write $out" >&2
rm -f "$tmp"
return 1
fi
local rc=0
if [ -f "$out" ] && [ -w "$out" ]; then
cat "$tmp" > "$out" || rc=1
elif install -m 0640 "$tmp" "$out" 2>/dev/null; then :
elif sudo install -m 0640 -o "$(id -un)" -g "$(id -gn)" "$tmp" "$out" 2>/dev/null; then :
else
echo "!! cannot write $out (need file write access or passwordless sudo)" >&2
rc=1
fi
if [ "$rc" = 0 ] && [ -n "${THERMOGRAPH_SECRETS_ENV_FILE_MIRROR:-}" ]; then
if ! mkdir -p "$(dirname "$THERMOGRAPH_SECRETS_ENV_FILE_MIRROR")" \
|| ! install -m 0600 "$tmp" "$THERMOGRAPH_SECRETS_ENV_FILE_MIRROR"; then
echo "!! cannot write mirror at $THERMOGRAPH_SECRETS_ENV_FILE_MIRROR" >&2
rc=1
fi
fi
rm -f "$tmp"
return "$rc"
}
# ---------------------------------------------------------------------------
# CENTRALIS
# ---------------------------------------------------------------------------
# /etc/centralis.env is a different file, with a different consumer, and therefore a
# different output shape. render-secrets.sh:206-233 carries the full argument; the
# short version is that it is consumed by exactly one thing and that thing is a shell:
#
# sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up'
#
# `set -a; . file` runs every line through bash's whole expansion pipeline — quote
# removal, parameter expansion, command substitution, field splitting. Raw dotenv is
# therefore not a safe representation here. CENTRALIS_TOKENS is JSON: rendered
# unquoted, bash strips its quotes, and Centralis fails CLOSED on the malformed result
# by serving a single `shared` identity — indistinguishable from the file never having
# been written. That is the 2026-07-24 incident. So this path emits POSIX
# single-quoted assignments and PROVES them by sourcing the result in a clean shell
# before anything touches $dest.
#
# This mirrors render_centralis_secrets rather than sharing with it, for the same
# reason given above thermograph_render_openbao: the SOPS path stays byte-identical
# and all new risk stays in this file. Consolidate the two when the SOPS path is being
# deleted anyway, not before.
# _thermograph_openbao_login <approle_file> <addr> <ca> -> token on STDOUT
#
# Separate from the login inline in thermograph_openbao_source deliberately: that
# function is now exercised by prod and beta parity and is left untouched.
_thermograph_openbao_login() {
local approle="$1" addr="$2" ca="$3"
local creds role_id secret_id
if [ -r "$approle" ]; then
creds=$(cat "$approle")
else
creds=$(sudo cat "$approle" 2>/dev/null || true)
fi
[ -n "$creds" ] || {
echo "!! cannot read OpenBao AppRole credentials at $approle (need sudo)" >&2
return 1
}
role_id=$(printf '%s\n' "$creds" | sed -n '1p')
secret_id=$(printf '%s\n' "$creds" | sed -n '2p')
[ -n "$role_id" ] && [ -n "$secret_id" ] || {
echo "!! $approle malformed: expected role_id on line 1, secret_id on line 2" >&2
return 1
}
export BAO_ADDR="$addr"
[ -f "$ca" ] && export BAO_CACERT="$ca"
bao write -field=token auth/approle/login \
role_id="$role_id" secret_id="$secret_id" 2>/dev/null
}
# thermograph_render_openbao_centralis <env_name> [dest]
thermograph_render_openbao_centralis() {
local env_name="${1:?env name required}"
local dest="${2:-${CENTRALIS_RENDER_DEST:-/etc/centralis.env}}"
local addr="${THERMOGRAPH_BAO_ADDR:-https://10.10.0.1:8200}"
local mount="${THERMOGRAPH_BAO_MOUNT:-thermograph}"
local ca="${THERMOGRAPH_BAO_CACERT:-/etc/thermograph/openbao-ca.crt}"
local approle="${THERMOGRAPH_BAO_CENTRALIS_APPROLE:-/etc/thermograph/openbao-approle-centralis}"
command -v bao >/dev/null 2>&1 || {
echo "!! bao CLI not installed but this host is configured for the openbao backend" >&2
return 1
}
command -v python3 >/dev/null 2>&1 || {
echo "!! python3 not installed; needed to quote values safely for a sourced file" >&2
return 1
}
echo "==> Rendering ${dest} from OpenBao (${mount}/centralis/${env_name})"
local token
token=$(_thermograph_openbao_login "$approle" "$addr" "$ca") || return 1
[ -n "$token" ] || {
echo "!! OpenBao AppRole login failed for centralis" >&2
echo "!! check that OpenBao at $addr is reachable and UNSEALED" >&2
return 1
}
export BAO_TOKEN="$token"
# ONE 0700 temp directory holding plaintext, so cleanup is a single rm on every
# path. Deliberately not a `trap ... RETURN`: this file is SOURCED, and such a trap
# persists into the caller's shell and re-fires on its next `source`.
local work rc=0
work=$(mktemp -d) || { echo "!! mktemp -d failed" >&2; return 1; }
chmod 700 "$work"
# Subshell so no failure path can skip the cleanup, and so umask cannot leak out.
# Every step carries its own `|| exit 1`: `set -e` is DISABLED inside a compound
# command that is the left operand of `||`, which this subshell is.
(
umask 077
bao kv get -format=json -mount="$mount" "centralis/${env_name}" \
> "$work/kv.json" 2>/dev/null || {
echo "!! cannot read ${mount}/centralis/${env_name}" >&2
exit 1
}
python3 - "$work/kv.json" "$work/plain.json" <<'UNWRAP' || exit 1
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
data = doc.get("data", {}).get("data", {}) or {}
if not data:
sys.stderr.write("!! OpenBao returned zero keys for centralis\n")
sys.exit(1)
json.dump(data, open(sys.argv[2], "w", encoding="utf-8"))
UNWRAP
python3 - "$work/plain.json" "$work/out.env" "$work/want.json" <<'EMIT' || exit 1
import json, re, sys
src, out_env, want_json = sys.argv[1], sys.argv[2], sys.argv[3]
NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
def shell_quote(v):
"""POSIX single-quoting. Inside '...' every byte is literal except ' itself,
which is closed, backslash-escaped and reopened. There is no escape sequence
to get wrong and no character class to keep up to date: it is total, and
total is the only property worth having for a file bash will expand."""
return "'" + v.replace("'", "'\\''") + "'"
def scalar(k, v):
if isinstance(v, str):
return v
if isinstance(v, bool): # before int: bool is an int in Python
return "true" if v else "false"
if isinstance(v, (int, float)):
return json.dumps(v)
if v is None:
return ""
sys.stderr.write(
"!! %s is a %s, but an env file holds only scalars\n" % (k, type(v).__name__))
sys.exit(1)
data = json.load(open(src, encoding="utf-8"))
want, lines = {}, []
for k, v in data.items():
if not NAME.match(k):
sys.stderr.write("!! %r is not a usable shell variable name\n" % (k,))
sys.exit(1)
want[k] = scalar(k, v)
lines.append("%s=%s\n" % (k, shell_quote(want[k])))
with open(out_env, "w", encoding="utf-8") as fh:
fh.write("# Rendered by infra/deploy/render-secrets-openbao.sh from OpenBao.\n")
fh.write("# DO NOT EDIT BY HAND: the next render overwrites this file, and a\n")
fh.write("# hand-edit is how the token registry was lost on 2026-07-24.\n")
fh.write("# Rotate with `bao kv put` against thermograph/centralis/<env> instead.\n")
fh.writelines(lines)
with open(want_json, "w", encoding="utf-8") as fh:
json.dump(want, fh)
sys.stderr.write(" %d keys quoted\n" % len(want))
EMIT
# PROVE IT. Source the rendered file exactly the way the deploy does — clean
# environment, `set -a`, nothing inherited that could mask a dropped key — and
# read every value back. This is the check the 2026-07-24 file could not pass.
# shellcheck disable=SC2016 # single quotes are the point: "$1" must be expanded
# by the inner `bash -c` against the argument after `_`, not by this shell.
env -i PATH="$PATH" bash -c \
'set -a; . "$1"; set +a; exec python3 -c "
import json, os, sys
sys.stdout.write(json.dumps(dict(os.environ)))"' _ "$work/out.env" \
> "$work/got.json" || exit 1
python3 - "$work/want.json" "$work/got.json" <<'VERIFY' || exit 1
import json, sys
want = json.load(open(sys.argv[1], encoding="utf-8"))
got = json.load(open(sys.argv[2], encoding="utf-8"))
bad = []
for k, v in want.items():
if k not in got:
bad.append("%s: not set at all after sourcing" % k)
elif got[k] != v:
# NEVER print either value. The difference is the finding; the content is
# not, and this runs inside a deploy log.
bad.append("%s: survives sourcing as a DIFFERENT value (len %d -> %d)"
% (k, len(v), len(got[k])))
if bad:
sys.stderr.write("!! the rendered file does not round-trip through bash:\n")
for b in bad:
sys.stderr.write("!! %s\n" % b)
sys.exit(1)
# CENTRALIS_TOKENS is the reason for all of the above: JSON, full of double quotes,
# in a file bash expands. Centralis fails CLOSED on malformed JSON — it drops to the
# single `shared` identity, which looks exactly like the registry never having been
# configured. Refuse to write a file that would do that.
raw = got.get("CENTRALIS_TOKENS", "")
if raw:
try:
reg = json.loads(raw)
except ValueError as exc:
sys.stderr.write("!! CENTRALIS_TOKENS is not valid JSON after sourcing: %s\n" % exc)
sys.exit(1)
if not isinstance(reg, dict) or not reg:
sys.stderr.write("!! CENTRALIS_TOKENS must be a non-empty JSON object\n")
sys.exit(1)
if not all(isinstance(s, str) and isinstance(t, str) and t for s, t in reg.items()):
sys.stderr.write("!! CENTRALIS_TOKENS must map subject -> non-empty token string\n")
sys.exit(1)
# Subjects are names on audit lines, not credentials. Printing them is the whole
# point: it is how an operator sees at a glance that nobody was lost.
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
% (len(reg), ", ".join(sorted(reg))))
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
VERIFY
) || rc=1
# Only now, with a file read back through bash and matched value for value, does
# anything touch $dest. 0600: four bearer credentials for the estate's control
# plane, with no group that needs them.
if [ "$rc" -eq 0 ]; then
if [ -f "$dest" ] && [ -w "$dest" ]; then
cat "$work/out.env" > "$dest" || rc=1
elif install -m 0600 "$work/out.env" "$dest" 2>/dev/null; then :
elif sudo install -m 0600 -o "$(id -un)" -g "$(id -gn)" "$work/out.env" "$dest" 2>/dev/null; then :
else
echo "!! cannot write ${dest} (need file write access or passwordless sudo)" >&2
rc=1
fi
else
echo "!! render aborted; ${dest} left untouched" >&2
fi
rm -rf "$work"
return "$rc"
}