From e7d7c22cc7ffa0a0cda711a48c45fd6957e8b405 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Tue, 8 Sep 2026 03:23:10 -0600 Subject: [PATCH 1/2] fix: TOML-escape iris token and base URL in docker entrypoint The entrypoint escaped RITE_IRIS_API_TOKEN SQL-style inside a TOML literal string. TOML literal strings cannot contain single quotes, so a token like o'brien produced an unparseable rite.toml and a crashing container. base_url was also interpolated unescaped into a basic string, breaking on quotes/backslashes. Emit TOML basic strings with backslash and double-quote escaped, newline as \n, tab left raw (legal), and every other control byte plus DEL as \uXXXX. Use GNU sed -z so embedded newlines are matchable without a sentinel byte. Add an integration test that runs the real entrypoint and parses its output through rite's production load_config: quote/backslash/control/multiline round- trips for both fields, full 0x01-0x1F/0x7F sweep, STX sentinel regression, token absence, blank base_url, and token-never-logged. Closes COD-470. --- crates/rite-server/tests/docker_entrypoint.rs | 218 ++++++++++++++++++ docker-entrypoint.sh | 52 ++++- 2 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 crates/rite-server/tests/docker_entrypoint.rs diff --git a/crates/rite-server/tests/docker_entrypoint.rs b/crates/rite-server/tests/docker_entrypoint.rs new file mode 100644 index 0000000..a0458f2 --- /dev/null +++ b/crates/rite-server/tests/docker_entrypoint.rs @@ -0,0 +1,218 @@ +//! Integration test for `docker-entrypoint.sh` TOML materialization. +//! +//! The entrypoint generates `rite.toml` from `RITE_IRIS_BASE_URL` and +//! `RITE_IRIS_API_TOKEN` at container start. Generated files must be valid +//! TOML parseable by Rite's own production parser (`rite_server::load_config`) +//! and must round-trip the exact environment values, including values +//! containing quotes, backslashes, and control characters. +//! +//! Regression coverage for COD-470: the entrypoint previously escaped the +//! token as a TOML literal string (`'...'`) using SQL-style quote doubling, +//! which cannot represent a single quote at all — a token like `o'brien` +//! produced an unparseable config and a crashing container. A first fix +//! attempt also escaped only SOH and used STX as a newline sentinel, which +//! collided with real STX bytes and let other control bytes through raw. + +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CALL: AtomicUsize = AtomicUsize::new(0); + +fn repo_root() -> std::path::PathBuf { + // CARGO_MANIFEST_DIR = crates/rite-server + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +/// Tests run in parallel threads within one process, so the PID alone does +/// not isolate their temp directories. +fn scratch_dir(tag: &str) -> std::path::PathBuf { + let n = CALL.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("rite-entrypoint-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn materialize(base_url: &str, api_token: Option<&str>) -> String { + let dir = scratch_dir("test"); + let conf = dir.join("rite.toml"); + + let mut cmd = Command::new("/bin/sh"); + cmd.arg(repo_root().join("docker-entrypoint.sh")) + .env("RITE_CONFIG", &conf) + .env("RITE_IRIS_BASE_URL", base_url) + .arg("true"); + if let Some(token) = api_token { + cmd.env("RITE_IRIS_API_TOKEN", token); + } else { + cmd.env_remove("RITE_IRIS_API_TOKEN"); + } + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "entrypoint failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let generated = std::fs::read_to_string(&conf).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + generated +} + +/// Assert a generated config parses through the production parser and +/// round-trips the exact env values. Failure messages deliberately omit the +/// generated TOML so the token value is never echoed into test output +/// (COD-433 secret-safe precedent). +fn assert_round_trip(base_url: &str, api_token: Option<&str>) { + let generated = materialize(base_url, api_token); + let config = rite_server::load_config(&generated) + .unwrap_or_else(|e| panic!("generated TOML failed to parse: {e}")); + let iris = config + .sources + .iris + .expect("iris source present when base_url is set"); + assert!(iris.enabled, "iris must be enabled when base_url is set"); + assert_eq!( + iris.base_url, + base_url, + "base_url round-trip failed (length {} vs {})", + iris.base_url.len(), + base_url.len() + ); + match api_token { + Some(expected) => assert_eq!( + iris.api_token.as_deref(), + Some(expected), + "api_token round-trip failed (length {} vs {})", + iris.api_token.as_deref().map_or(0, str::len), + expected.len() + ), + None => assert!( + iris.api_token.is_none(), + "api_token must be absent when unset" + ), + } +} + +#[test] +fn plain_values_round_trip() { + assert_round_trip("https://iris.invalid:8080", Some("secret")); +} + +#[test] +fn token_absent_when_unset() { + assert_round_trip("https://iris.invalid:8080", None); +} + +#[test] +fn single_quote_token_round_trips() { + // COD-470 regression: `'o''brien'` is not valid TOML escaping; the + // original literal-string scheme could not represent this token at all. + assert_round_trip("https://iris.example", Some("o'brien")); +} + +#[test] +fn double_quote_token_round_trips() { + assert_round_trip("https://iris.example", Some("to\"ken")); +} + +#[test] +fn backslash_token_round_trips() { + assert_round_trip("https://iris.example", Some("back\\slash")); +} + +#[test] +fn mixed_delimiters_token_round_trips() { + assert_round_trip("https://x\"a\\b", Some("q'q\"b\\s")); +} + +#[test] +fn multiline_token_round_trips() { + // Raw newlines terminate a TOML basic string; the entrypoint must emit + // the \n escape instead. Environment variables may contain newlines. + assert_round_trip("https://iris.example", Some("two\nlines")); +} + +#[test] +fn crlf_token_round_trips() { + assert_round_trip("https://iris.example", Some("cr\r\nlf")); +} + +#[test] +fn tab_token_round_trips() { + // Tab is the one control byte that IS legal raw in a TOML basic string. + assert_round_trip("https://iris.example", Some("ta\tb")); +} + +#[test] +fn stx_token_round_trips() { + // Second-generation regression: an earlier fix used STX (0x02) as a + // newline sentinel, so a real STX byte was emitted as `\n`. + assert_round_trip("https://iris.example", Some("s\u{02}x")); +} + +#[test] +fn all_other_control_bytes_and_del_round_trip() { + // Every control byte except tab, plus DEL, must be \uXXXX-escaped and + // round-trip exactly. Newline is covered separately above. + let bytes: Vec = (0x01u8..=0x1f) + .chain(0x7f..=0x7f) + .filter(|b| *b != b'\t' && *b != b'\n') + .map(|b| b as char) + .collect(); + let token: String = std::iter::once('x') + .chain(bytes.iter().copied()) + .chain(std::iter::once('y')) + .collect(); + assert_round_trip("https://iris.example", Some(&token)); +} + +#[test] +fn control_chars_in_base_url_round_trip() { + // base_url goes through the same escaper; it must survive control + // bytes, quotes, and backslashes too. + assert_round_trip("https://x\ny\"z\\w\u{01}", None); +} + +#[test] +fn blank_base_url_emits_no_iris_section() { + let generated = materialize("", None); + let config = rite_server::load_config(&generated).unwrap(); + assert!( + config.sources.iris.is_none(), + "empty base_url must not emit [sources.iris]" + ); +} + +#[test] +fn generated_token_is_never_logged() { + // The entrypoint writes the token to the config file only; stdout and + // stderr must not echo it (COD-433 secret-safe precedent). + let dir = scratch_dir("log"); + let conf = dir.join("rite.toml"); + let output = Command::new("/bin/sh") + .arg(repo_root().join("docker-entrypoint.sh")) + .env("RITE_CONFIG", &conf) + .env("RITE_IRIS_BASE_URL", "https://iris.example") + .env("RITE_IRIS_API_TOKEN", "super-secret-token-value") + .arg("true") + .output() + .unwrap(); + assert!( + output.status.success(), + "entrypoint failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.contains("super-secret-token-value") + && !stderr.contains("super-secret-token-value"), + "token leaked to stdout/stderr" + ); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0e1b234..23ee448 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -14,7 +14,53 @@ fi IRIS_URL="${RITE_IRIS_BASE_URL:-}" IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN:-}" -IRIS_API_TOKEN_TOML=$(printf '%s' "$IRIS_API_TOKEN" | sed "s/'/''/g") + +# TOML basic-string escaping: backslash and double quote must be escaped, +# and control characters (everything except tab) plus DEL cannot appear raw +# in a basic string. GNU sed is required: -z processes the value as a single +# NUL-delimited record (environment values cannot contain NUL), which makes +# embedded newlines matchable without a placeholder byte. Values are also +# expected to be valid UTF-8 — like Rust's own env reading, which cannot +# yield non-UTF-8 strings, so no sanitization is attempted here. +toml_escape() { + printf '%s' "$1" | sed -z \ + -e 's/\\/\\\\/g' \ + -e 's/"/\\"/g' \ + -e 's/\x01/\\u0001/g' \ + -e 's/\x02/\\u0002/g' \ + -e 's/\x03/\\u0003/g' \ + -e 's/\x04/\\u0004/g' \ + -e 's/\x05/\\u0005/g' \ + -e 's/\x06/\\u0006/g' \ + -e 's/\x07/\\u0007/g' \ + -e 's/\x08/\\b/g' \ + -e 's/\x0a/\\n/g' \ + -e 's/\x0b/\\u000b/g' \ + -e 's/\x0c/\\f/g' \ + -e 's/\x0d/\\r/g' \ + -e 's/\x0e/\\u000e/g' \ + -e 's/\x0f/\\u000f/g' \ + -e 's/\x10/\\u0010/g' \ + -e 's/\x11/\\u0011/g' \ + -e 's/\x12/\\u0012/g' \ + -e 's/\x13/\\u0013/g' \ + -e 's/\x14/\\u0014/g' \ + -e 's/\x15/\\u0015/g' \ + -e 's/\x16/\\u0016/g' \ + -e 's/\x17/\\u0017/g' \ + -e 's/\x18/\\u0018/g' \ + -e 's/\x19/\\u0019/g' \ + -e 's/\x1a/\\u001a/g' \ + -e 's/\x1b/\\u001b/g' \ + -e 's/\x1c/\\u001c/g' \ + -e 's/\x1d/\\u001d/g' \ + -e 's/\x1e/\\u001e/g' \ + -e 's/\x1f/\\u001f/g' \ + -e 's/\x7f/\\u007f/g' +} + +IRIS_URL_TOML=$(toml_escape "$IRIS_URL") +IRIS_API_TOKEN_TOML=$(toml_escape "$IRIS_API_TOKEN") { echo "# Generated by docker-entrypoint.sh — do not edit in place." @@ -22,9 +68,9 @@ IRIS_API_TOKEN_TOML=$(printf '%s' "$IRIS_API_TOKEN" | sed "s/'/''/g") echo "" echo "[sources.iris]" echo "enabled = true" - echo "base_url = \"$IRIS_URL\"" + echo "base_url = \"$IRIS_URL_TOML\"" if [ -n "$IRIS_API_TOKEN" ]; then - echo "api_token = '$IRIS_API_TOKEN_TOML'" + echo "api_token = \"$IRIS_API_TOKEN_TOML\"" fi fi } > "$CONF" From 2bb0f571851949ee513325ccc71c914c6a305434 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Tue, 8 Sep 2026 04:11:28 -0600 Subject: [PATCH 2/2] fix: use printf instead of echo for generated TOML emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dash's builtin echo interprets backslash escapes in its arguments, so the \\n / \\b / \\f / \\r sequences produced by toml_escape were mangled when /bin/sh is dash — which it is on the production runtime image (Debian bookworm). Emit lines with printf, whose format string is the only escape-processed part. Verified under the actual production shell by running the script inside debian:bookworm-slim (dash + GNU sed 4.9). --- docker-entrypoint.sh | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 23ee448..25f72fb 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -63,14 +63,16 @@ IRIS_URL_TOML=$(toml_escape "$IRIS_URL") IRIS_API_TOKEN_TOML=$(toml_escape "$IRIS_API_TOKEN") { - echo "# Generated by docker-entrypoint.sh — do not edit in place." + printf '%s\n' "# Generated by docker-entrypoint.sh — do not edit in place." if [ -n "$IRIS_URL" ]; then - echo "" - echo "[sources.iris]" - echo "enabled = true" - echo "base_url = \"$IRIS_URL_TOML\"" + printf '\n%s\n' "[sources.iris]" + printf '%s\n' "enabled = true" + # printf, never echo: dash's builtin echo interprets \n, \\, \b, \f, \r + # in its arguments and would corrupt the escapes emitted above. The + # runtime image (Debian bookworm) uses dash as /bin/sh. + printf 'base_url = "%s"\n' "$IRIS_URL_TOML" if [ -n "$IRIS_API_TOKEN" ]; then - echo "api_token = \"$IRIS_API_TOKEN_TOML\"" + printf 'api_token = "%s"\n' "$IRIS_API_TOKEN_TOML" fi fi } > "$CONF"