Joint image: derive connection strings and provisioning from plain env vars #179

Merged
rob merged 5 commits from feature/task-237-joint-env-config into main 2026-08-16 18:12:27 +00:00
Owner

Vikunja task 237. Lets the joint image be deployed as a single container plus Postgres — a TrueNAS custom app, say — from a handful of plain PLACEMARK_*/JWT_* environment variables, with no connection-string syntax to get wrong and no separate provisioning sidecar.

entrypoint.sh composes ConnectionStrings__PlaceMark, ConnectionStrings__PlaceMarkSchemaUpgrade, Jwt__Issuer, Jwt__Audience and Jwt__SigningKey from the plain variables, but only where the corresponding value is not already supplied explicitly — docker-compose.yml and CI keep working unchanged, by setting those directly. Provisioning (PlaceMark.Database.dll provision) runs before the schema upgrade only when both PLACEMARK_DB_ADMIN_USER and PLACEMARK_DB_ADMIN_PASSWORD are set — one without the other fails loudly, naming both — and both are unset before the API starts, so the long-lived serving process never inherits a role-creating credential. Every missing required variable fails the container before any command runs, naming itself.

ADR-0153 records the trade-off this makes against ADR-0031's credential separation, and why ADR-0031 itself gains no Answered by annotation (its own frozen text states production's shape as fixed, not left open — checked against ADR-0107's condition 3).

CI's existing joint-image steps gained explicit Jwt__Issuer/Jwt__Audience to keep exercising the "explicit Jwt__* wins" path with no behaviour change.

Verified locally, against a real, throwaway Postgres (built the image, ran it on a fresh database with only the new variables): roles created, schema applied, both ports served, /health/ready 200. Restarted the same container against the same database: Password reset on: placemark_app, placemark_upgrade and No pending scripts — a clean no-op, still serving. Confirmed the explicit-ConnectionStrings__*/Jwt__* path still works with no PLACEMARK_* variable set at all. Confirmed via /proc/<api-pid>/environ inside the container that the API process itself never carries PLACEMARK_DB_ADMIN_USER/PASSWORD, while docker inspect still does — exactly the honest limit ADR-0153 states. Confirmed one admin variable set without the other, and a missing PLACEMARK_DB_HOST, each fail loudly before any command runs, naming the variable.

Not exercised: the published, multi-arch registry image (only a local docker build of this branch) — same limit pull_request CI has.

Vikunja task 237. Lets the joint image be deployed as a single container plus Postgres — a TrueNAS custom app, say — from a handful of plain `PLACEMARK_*`/`JWT_*` environment variables, with no connection-string syntax to get wrong and no separate provisioning sidecar. `entrypoint.sh` composes `ConnectionStrings__PlaceMark`, `ConnectionStrings__PlaceMarkSchemaUpgrade`, `Jwt__Issuer`, `Jwt__Audience` and `Jwt__SigningKey` from the plain variables, but only where the corresponding value is not already supplied explicitly — `docker-compose.yml` and CI keep working unchanged, by setting those directly. Provisioning (`PlaceMark.Database.dll provision`) runs before the schema upgrade only when both `PLACEMARK_DB_ADMIN_USER` and `PLACEMARK_DB_ADMIN_PASSWORD` are set — one without the other fails loudly, naming both — and both are unset before the API starts, so the long-lived serving process never inherits a role-creating credential. Every missing required variable fails the container before any command runs, naming itself. ADR-0153 records the trade-off this makes against ADR-0031's credential separation, and why ADR-0031 itself gains no `Answered by` annotation (its own frozen text states production's shape as fixed, not left open — checked against ADR-0107's condition 3). CI's existing joint-image steps gained explicit `Jwt__Issuer`/`Jwt__Audience` to keep exercising the "explicit `Jwt__*` wins" path with no behaviour change. **Verified locally, against a real, throwaway Postgres** (built the image, ran it on a fresh database with only the new variables): roles created, schema applied, both ports served, `/health/ready` 200. Restarted the same container against the same database: `Password reset on: placemark_app, placemark_upgrade` and `No pending scripts` — a clean no-op, still serving. Confirmed the explicit-`ConnectionStrings__*`/`Jwt__*` path still works with no `PLACEMARK_*` variable set at all. Confirmed via `/proc/<api-pid>/environ` inside the container that the API process itself never carries `PLACEMARK_DB_ADMIN_USER`/`PASSWORD`, while `docker inspect` still does — exactly the honest limit ADR-0153 states. Confirmed one admin variable set without the other, and a missing `PLACEMARK_DB_HOST`, each fail loudly before any command runs, naming the variable. Not exercised: the published, multi-arch registry image (only a local `docker build` of this branch) — same limit `pull_request` CI has.
Derive the joint image's connection strings and provisioning from plain env vars
All checks were successful
CI / build (pull_request) Successful in 3m8s
CI / container-images (pull_request) Successful in 58s
CI / e2e (pull_request) Successful in 2m42s
98d0b8e227
rob left a comment

Verdict: changes needed

Connection-string injection via unescaped password interpolation — confirmed against Npgsql 10.0.3, not just theorised. NpgsqlConnectionStringBuilder parses ConnectionStrings__* as ordinary keyword=value;keyword=value pairs; a duplicate keyword lets the later occurrence win. Built exactly as this entrypoint builds it (Host=db.example;Port=5432;Database=placemark;Username=placemark_app;Password=<value>):

  • Password = abc;Host=evil.example → parses with no exception, Host silently becomes evil.example, Password truncates to abc. Same result with abc;SSL Mode=Disable.
  • Password = abc;def (a bare ;, no following =) → ArgumentException: Format of the initialization string does not conform to specification, thrown from inside PlaceMark.Database.dll/the API itself — well past the entrypoint's own require_var guards, so this reads exactly like a wrong-password error rather than the connection-string bug it actually is.
  • Password starting with ' or " → same exception.

;, ', " are all legal characters in a generated password (ADR-0031 restricts provisioned passwords to printable ASCII only, nothing narrower), and plausible from a TrueNAS UI paste. This affects all three password variables and the admin username, each interpolated unescaped and unquoted into a hand-built connection string.

Needs either proper escaping per Npgsql's own connection-string quoting rules (wrap the value in "...", double any embedded "), or an explicit up-front rejection of ;/'/"/= in these variables with a named, fail-loud error. Left as-is, a legal password either crashes the container with a confusing exception or, worse, silently redirects the connection or disables TLS.

Everything else checks out: precedence guards are on the right variable in each case and independent per connection string; the admin-credential unset runs before both nginx and dotnet are forked, so the /proc/<api-pid>/environ claim holds; PLACEMARK_SEED_ROLE_PASSWORD is correctly never read or forwarded; the admin-pair fail-loud check is unconditional and correctly ordered ahead of provisioning; no bashisms in the new block; the ci.yml change is additive and behaviour-preserving for the "explicit Jwt__* wins" path; ADR-0153's Answered by reasoning holds up against ADR-0031's actual frozen text ("the shape is fixed by this record... No further decision is needed").

Verdict: changes needed **Connection-string injection via unescaped password interpolation — confirmed against Npgsql 10.0.3, not just theorised.** `NpgsqlConnectionStringBuilder` parses `ConnectionStrings__*` as ordinary `keyword=value;keyword=value` pairs; a duplicate keyword lets the later occurrence win. Built exactly as this entrypoint builds it (`Host=db.example;Port=5432;Database=placemark;Username=placemark_app;Password=<value>`): - `Password` = `abc;Host=evil.example` → parses with **no exception**, `Host` silently becomes `evil.example`, `Password` truncates to `abc`. Same result with `abc;SSL Mode=Disable`. - `Password` = `abc;def` (a bare `;`, no following `=`) → `ArgumentException: Format of the initialization string does not conform to specification`, thrown from inside `PlaceMark.Database.dll`/the API itself — well past the entrypoint's own `require_var` guards, so this reads exactly like a wrong-password error rather than the connection-string bug it actually is. - `Password` starting with `'` or `"` → same exception. `;`, `'`, `"` are all legal characters in a generated password (ADR-0031 restricts provisioned passwords to printable ASCII only, nothing narrower), and plausible from a TrueNAS UI paste. This affects all three password variables and the admin username, each interpolated unescaped and unquoted into a hand-built connection string. Needs either proper escaping per Npgsql's own connection-string quoting rules (wrap the value in `"..."`, double any embedded `"`), or an explicit up-front rejection of `;`/`'`/`"`/`=` in these variables with a named, fail-loud error. Left as-is, a legal password either crashes the container with a confusing exception or, worse, silently redirects the connection or disables TLS. Everything else checks out: precedence guards are on the right variable in each case and independent per connection string; the admin-credential `unset` runs before both `nginx` and `dotnet` are forked, so the `/proc/<api-pid>/environ` claim holds; `PLACEMARK_SEED_ROLE_PASSWORD` is correctly never read or forwarded; the admin-pair fail-loud check is unconditional and correctly ordered ahead of provisioning; no bashisms in the new block; the `ci.yml` change is additive and behaviour-preserving for the "explicit `Jwt__*` wins" path; ADR-0153's `Answered by` reasoning holds up against ADR-0031's actual frozen text ("the shape is fixed by this record... No further decision is needed").
@ -14,0 +33,4 @@
if [ -z "${ConnectionStrings__PlaceMark:-}" ]; then
require_var PLACEMARK_DB_HOST
require_var PLACEMARK_APP_ROLE_PASSWORD
ConnectionStrings__PlaceMark="Host=${PLACEMARK_DB_HOST};Port=${PLACEMARK_DB_PORT:-5432};Database=${PLACEMARK_DB_NAME:-placemark};Username=placemark_app;Password=${PLACEMARK_APP_ROLE_PASSWORD}"
Author
Owner

Unescaped interpolation of PLACEMARK_APP_ROLE_PASSWORD into an Npgsql connection string. A password containing ; lets a later keyword (e.g. ;Host=evil.example) silently override Host with no error — verified against Npgsql 10.0.3. See the review body for the reproduction and suggested fix; same issue applies at lines 43 and 78.

Unescaped interpolation of PLACEMARK_APP_ROLE_PASSWORD into an Npgsql connection string. A password containing `;` lets a later keyword (e.g. `;Host=evil.example`) silently override Host with no error — verified against Npgsql 10.0.3. See the review body for the reproduction and suggested fix; same issue applies at lines 43 and 78.
@ -14,0 +40,4 @@
if [ -z "${ConnectionStrings__PlaceMarkSchemaUpgrade:-}" ]; then
require_var PLACEMARK_DB_HOST
require_var PLACEMARK_UPGRADE_ROLE_PASSWORD
ConnectionStrings__PlaceMarkSchemaUpgrade="Host=${PLACEMARK_DB_HOST};Port=${PLACEMARK_DB_PORT:-5432};Database=${PLACEMARK_DB_NAME:-placemark};Username=placemark_upgrade;Password=${PLACEMARK_UPGRADE_ROLE_PASSWORD}"
Author
Owner

Same unescaped-interpolation issue as ConnectionStrings__PlaceMark above, here for PLACEMARK_UPGRADE_ROLE_PASSWORD.

Same unescaped-interpolation issue as ConnectionStrings__PlaceMark above, here for PLACEMARK_UPGRADE_ROLE_PASSWORD.
@ -14,0 +75,4 @@
# drop an existing seed role on every run (ProvisionCommand's own documented behaviour,
# ADR-0031, ADR-0123). Nothing here reads or forwards it.
echo "entrypoint: provisioning roles as $PLACEMARK_DB_ADMIN_USER"
ConnectionStrings__PlaceMarkProvisioningTarget="Host=${PLACEMARK_DB_HOST};Port=${PLACEMARK_DB_PORT:-5432};Database=${PLACEMARK_DB_NAME:-placemark};Username=${PLACEMARK_DB_ADMIN_USER};Password=${PLACEMARK_DB_ADMIN_PASSWORD}" \
Author
Owner

Same issue again, here for both PLACEMARK_DB_ADMIN_USER and PLACEMARK_DB_ADMIN_PASSWORD — the most sensitive of the three, since this is the role-creating credential.

Same issue again, here for both PLACEMARK_DB_ADMIN_USER and PLACEMARK_DB_ADMIN_PASSWORD — the most sensitive of the three, since this is the role-creating credential.
Quote connection-string values to stop password-driven injection
All checks were successful
CI / build (pull_request) Successful in 3m7s
CI / container-images (pull_request) Successful in 48s
CI / e2e (pull_request) Successful in 2m42s
59840f27c4
Author
Owner

Fixed in 59840f2. entrypoint.sh gains pg_quote(): wraps every PLACEMARK_*-sourced value interpolated into a connection string in double quotes, doubling any embedded double quote — the one escape Npgsql's keyword=value;keyword=value grammar defines. Applied to PLACEMARK_DB_HOST, PLACEMARK_DB_PORT, PLACEMARK_DB_NAME, all three passwords, and PLACEMARK_DB_ADMIN_USER — not only the passwords, since a ; in Host/Port/Database is the same defect. Jwt__* isn't touched (not a delimited connection string).

Proved via NpgsqlConnectionStringBuilder 10.0.3 (extended the scratch project at /tmp/.../scratchpad/npgsql-test): every case from your review — abc;Host=evil.example, abc;SSL Mode=Disable, a bare abc;def, a leading '/", both quote characters together with ;, a bare =, leading/trailing whitespace, a backslash — now round-trips to the exact original value with no exception, where before, the first two silently redirected Host/disabled the keyword and the rest threw ArgumentException. Cross-checked the same pg_quote() shell output against busybox ash directly (not just reasoned about) — outputs match the C# harness byte-for-byte.

Then proved it against a real, throwaway Postgres, live: built the image, provisioned placemark_app with password abc;Host=evil.example;SSL Mode=Disable and placemark_upgrade with a"b';Host=evil;def, and confirmed both psql -U <role> with the literal malicious password and the container's own /health/ready (200) authenticate correctly against the real host — no redirect, no truncation. Restarted the same container with the same adversarial passwords: still a clean no-op (Password reset on: ..., No pending scripts), still serving.

Quoting alone is sufficient — no character needs rejecting; the doubling rule is total over the byte range a shell variable can hold.

Not added as an ADR-0153 consequence: this is a correctness fix to the composition the record already decided to do, not a new trade-off, so it's one paragraph in the Decision section instead (ADR-0153 hasn't merged yet, so this is a direct edit, not a supersession).

Fixed in 59840f2. `entrypoint.sh` gains `pg_quote()`: wraps every `PLACEMARK_*`-sourced value interpolated into a connection string in double quotes, doubling any embedded double quote — the one escape Npgsql's `keyword=value;keyword=value` grammar defines. Applied to `PLACEMARK_DB_HOST`, `PLACEMARK_DB_PORT`, `PLACEMARK_DB_NAME`, all three passwords, and `PLACEMARK_DB_ADMIN_USER` — not only the passwords, since a `;` in Host/Port/Database is the same defect. `Jwt__*` isn't touched (not a delimited connection string). Proved via `NpgsqlConnectionStringBuilder` 10.0.3 (extended the scratch project at `/tmp/.../scratchpad/npgsql-test`): every case from your review — `abc;Host=evil.example`, `abc;SSL Mode=Disable`, a bare `abc;def`, a leading `'`/`"`, both quote characters together with `;`, a bare `=`, leading/trailing whitespace, a backslash — now round-trips to the exact original value with no exception, where before, the first two silently redirected `Host`/disabled the keyword and the rest threw `ArgumentException`. Cross-checked the same `pg_quote()` shell output against busybox `ash` directly (not just reasoned about) — outputs match the C# harness byte-for-byte. Then proved it against a real, throwaway Postgres, live: built the image, provisioned `placemark_app` with password `abc;Host=evil.example;SSL Mode=Disable` and `placemark_upgrade` with `a"b';Host=evil;def`, and confirmed both `psql -U <role>` with the literal malicious password and the container's own `/health/ready` (200) authenticate correctly against the real host — no redirect, no truncation. Restarted the same container with the same adversarial passwords: still a clean no-op (`Password reset on: ...`, `No pending scripts`), still serving. Quoting alone is sufficient — no character needs rejecting; the doubling rule is total over the byte range a shell variable can hold. Not added as an ADR-0153 consequence: this is a correctness fix to the composition the record already decided to do, not a new trade-off, so it's one paragraph in the Decision section instead (ADR-0153 hasn't merged yet, so this is a direct edit, not a supersession).
rob left a comment

Verdict: mergeable

Re-verified independently, not by trusting the round-trip table: extended the same scratch project (real NpgsqlConnectionStringBuilder 10.0.3) with a PgQuote() mirror and ran it — plus several cases not in the PR's own list (a value that is only a ", a trailing backslash, an empty value, a leading single quote, both quote characters combined with a ;) — and every one round-trips exactly, host/port/database/username all untouched. A quoted Port="5432" also parses fine, so nothing that previously worked (docker-compose.yml's explicit path, which still bypasses pg_quote entirely and is unaffected by any of this) is at risk. Backslash has no special meaning to Npgsql's grammar — confirmed, not assumed. The original injection (;Host=evil.example silently redirecting the connection) no longer reproduces; it now round-trips as literal password content.

Cross-checked the shell implementation itself, not just its C# mirror: ran pg_quote() verbatim under real busybox ash on alpine:3.23.5 (matching what the PR reports) against the same adversarial set. One real divergence from the C# mirror, not covered by the PR's own "leading/trailing whitespace" case: pg_quote's inner $(...) is a command substitution, and POSIX strips trailing newlines from command substitution output — a password ending in \n is silently truncated before quoting, so the composed connection string's password differs from the literal value. This is narrower than the original defect (no keyword injection, no redirection — a truncation, not corruption of structure) and is largely self-excluding in practice: RoleProvisioner.RejectPasswordThatCannotBeHashedHere already refuses any password outside printable ASCII (which excludes \n) whenever provisioning runs in the same invocation, so the gap only bites for a role password supplied fresh on a non-provisioning run against a role provisioned some other way. Not a blocker; worth a one-line comment on pg_quote (or a follow-up ticket) noting the limitation, since the current comment claims every case exercised round-trips and this one doesn't.

ADR-0153's new paragraph reads as a decision (states what's composed, why, and the escape rule), not a patch note, and the placement in Decision rather than Consequences is right — quoting correctly is a property of the composition already decided, not a new trade-off. One minor nit: the pg_quote code comment ("see the PR description for every case exercised") points at a PR conversation rather than a durable artefact; the ADR paragraph itself doesn't have this problem and would be the better anchor.

Everything cleared in the previous round still holds — precedence, the admin-unset ordering, PLACEMARK_SEED_ROLE_PASSWORD, fail-loud ordering, ci.yml, and the Answered by reasoning are all untouched by this diff.

Verdict: mergeable Re-verified independently, not by trusting the round-trip table: extended the same scratch project (real `NpgsqlConnectionStringBuilder` 10.0.3) with a `PgQuote()` mirror and ran it — plus several cases not in the PR's own list (a value that is only a `"`, a trailing backslash, an empty value, a leading single quote, both quote characters combined with a `;`) — and every one round-trips exactly, host/port/database/username all untouched. A quoted `Port="5432"` also parses fine, so nothing that previously worked (`docker-compose.yml`'s explicit path, which still bypasses `pg_quote` entirely and is unaffected by any of this) is at risk. Backslash has no special meaning to Npgsql's grammar — confirmed, not assumed. The original injection (`;Host=evil.example` silently redirecting the connection) no longer reproduces; it now round-trips as literal password content. Cross-checked the shell implementation itself, not just its C# mirror: ran `pg_quote()` verbatim under real busybox `ash` on `alpine:3.23.5` (matching what the PR reports) against the same adversarial set. One real divergence from the C# mirror, not covered by the PR's own "leading/trailing whitespace" case: `pg_quote`'s inner `$(...)` is a command substitution, and POSIX strips *trailing newlines* from command substitution output — a password ending in `\n` is silently truncated before quoting, so the composed connection string's password differs from the literal value. This is narrower than the original defect (no keyword injection, no redirection — a truncation, not corruption of structure) and is largely self-excluding in practice: `RoleProvisioner.RejectPasswordThatCannotBeHashedHere` already refuses any password outside printable ASCII (which excludes `\n`) whenever provisioning runs in the same invocation, so the gap only bites for a role password supplied fresh on a *non-provisioning* run against a role provisioned some other way. Not a blocker; worth a one-line comment on `pg_quote` (or a follow-up ticket) noting the limitation, since the current comment claims every case exercised round-trips and this one doesn't. ADR-0153's new paragraph reads as a decision (states what's composed, why, and the escape rule), not a patch note, and the placement in Decision rather than Consequences is right — quoting correctly is a property of the composition already decided, not a new trade-off. One minor nit: the `pg_quote` code comment ("see the PR description for every case exercised") points at a PR conversation rather than a durable artefact; the ADR paragraph itself doesn't have this problem and would be the better anchor. Everything cleared in the previous round still holds — precedence, the admin-unset ordering, `PLACEMARK_SEED_ROLE_PASSWORD`, fail-loud ordering, `ci.yml`, and the `Answered by` reasoning are all untouched by this diff.
@ -14,0 +44,4 @@
# the passwords: the same defect exists for a semicolon anywhere in Host, Port, Database or a
# username. The two role names below ("placemark_app", "placemark_upgrade") are literal
# constants this script writes itself, never quoted, because nothing about them varies.
printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"
Author
Owner

Real gap, not blocking: the inner $(...) here strips trailing newlines (POSIX command-substitution behaviour), so a value ending in \n is silently truncated rather than round-tripped — confirmed against real busybox ash on alpine:3.23.5. Narrower than the injection this function fixes (RoleProvisioner's printable-ASCII check already excludes \n whenever provisioning runs this invocation), but the comment above claims every exercised case round-trips exactly, and this one doesn't. Worth a one-line caveat or a follow-up ticket.

Real gap, not blocking: the inner $(...) here strips trailing newlines (POSIX command-substitution behaviour), so a value ending in \n is silently truncated rather than round-tripped — confirmed against real busybox ash on alpine:3.23.5. Narrower than the injection this function fixes (RoleProvisioner's printable-ASCII check already excludes \n whenever provisioning runs this invocation), but the comment above claims every exercised case round-trips exactly, and this one doesn't. Worth a one-line caveat or a follow-up ticket.
rob force-pushed feature/task-237-joint-env-config from 59840f27c4
All checks were successful
CI / build (pull_request) Successful in 3m7s
CI / container-images (pull_request) Successful in 48s
CI / e2e (pull_request) Successful in 2m42s
to 8361c1ec5a
Some checks failed
CI / build (pull_request) Successful in 3m21s
CI / container-images (pull_request) Successful in 49s
CI / e2e (pull_request) Failing after 3m14s
2026-08-16 17:38:58 +00:00
Compare
Author
Owner

Both addressed in 8361c1e.

  1. Trailing-newline truncation, fixed rather than documented. pg_quote()'s old $(printf '%s' "$1" | sed ...) lost every trailing newline — $(...) strips them unconditionally. Fixed with the standard sentinel guard: append printf X inside the same command substitution, then strip exactly the one trailing X with ${sentinel%X}, which leaves a genuine trailing newline untouched since it's no longer the last captured byte. Watched fail first (a password ending in \n came back truncated under the old version, confirmed in real busybox ash on alpine:3.23.5, including edge cases where the value itself ends in X or XX — correctly preserved, since %X only ever strips the single shortest trailing match), then watched pass with the fix, then round-tripped a password ending in \n\n and one with an internal newline through a real NpgsqlConnectionStringBuilder (10.0.3) — all round-trip exactly, zero mismatches across all 18 cases in the extended harness. Also proved end-to-end against a real, throwaway Postgres: provisioned placemark_app with a password containing a literal trailing \n, and both psql -U placemark_app and /health/ready (200) authenticate with that literal password.

  2. "see the PR description" replaced. The comment now restates the substance inline — what was proved and against what — rather than pointing at the PR body.

Not added to ADR-0153's Consequences. Too narrow to earn a line there: it's an implementation limit of one shell function's original draft, now closed, not a trade-off the record is accepting. Said so here rather than silently.

Rebased onto origin/main (task 238's PR #180 merged): conflict was only docs/adr/README.md's index table (both PRs added a row), resolved by hand and then confirmed byte-identical to scripts/regenerate-adr-readme.cs's own output — nothing else in that file moved. Rebuilt, reran PlaceMark.Architecture.Tests (123/123), and re-verified the whole flow against a fresh throwaway Postgres post-rebase, this time with no Oidc__* variables at all (task 238 made OIDC optional) and a role password ending in a literal newline: roles created, schema applied, /health/ready 200, and psql confirms the real Postgres role password is the newline-terminated string, unmodified.

Both addressed in 8361c1e. 1. **Trailing-newline truncation, fixed rather than documented.** `pg_quote()`'s old `$(printf '%s' "$1" | sed ...)` lost every trailing newline — `$(...)` strips them unconditionally. Fixed with the standard sentinel guard: append `printf X` inside the same command substitution, then strip exactly the one trailing `X` with `${sentinel%X}`, which leaves a genuine trailing newline untouched since it's no longer the last captured byte. Watched fail first (a password ending in `\n` came back truncated under the old version, confirmed in real busybox `ash` on `alpine:3.23.5`, including edge cases where the value itself ends in `X` or `XX` — correctly preserved, since `%X` only ever strips the single shortest trailing match), then watched pass with the fix, then round-tripped a password ending in `\n\n` and one with an internal newline through a real `NpgsqlConnectionStringBuilder` (10.0.3) — all round-trip exactly, zero mismatches across all 18 cases in the extended harness. Also proved end-to-end against a real, throwaway Postgres: provisioned `placemark_app` with a password containing a literal trailing `\n`, and both `psql -U placemark_app` and `/health/ready` (200) authenticate with that literal password. 2. **"see the PR description" replaced.** The comment now restates the substance inline — what was proved and against what — rather than pointing at the PR body. **Not added to ADR-0153's Consequences.** Too narrow to earn a line there: it's an implementation limit of one shell function's original draft, now closed, not a trade-off the record is accepting. Said so here rather than silently. **Rebased onto `origin/main`** (task 238's PR #180 merged): conflict was only `docs/adr/README.md`'s index table (both PRs added a row), resolved by hand and then confirmed byte-identical to `scripts/regenerate-adr-readme.cs`'s own output — nothing else in that file moved. Rebuilt, reran `PlaceMark.Architecture.Tests` (123/123), and re-verified the whole flow against a fresh throwaway Postgres post-rebase, this time with no `Oidc__*` variables at all (task 238 made OIDC optional) and a role password ending in a literal newline: roles created, schema applied, `/health/ready` 200, and `psql` confirms the real Postgres role password is the newline-terminated string, unmodified.
Retrigger CI: DistantMapClicksBothSurviveJourneyTests flaked on a modal-overlay intercept, unrelated to this change
Some checks failed
CI / build (pull_request) Successful in 3m11s
CI / container-images (pull_request) Failing after 48s
CI / e2e (pull_request) Has been cancelled
ee7949a8fb
Retrigger CI: WebUI probe failed to connect on a container start-up race, unrelated to this change
All checks were successful
CI / build (pull_request) Successful in 3m1s
CI / container-images (pull_request) Successful in 1m0s
CI / e2e (pull_request) Successful in 2m59s
fc18e6e2c4
Author
Owner

CI green at fc18e6e (run #747, 6m3s). The two runs in between (#745, #746) each failed on something unrelated to this branch's diff — DistantMapClicksBothSurviveJourneyTests on a modal-overlay pointer-event intercept, then the WebUI-only image probe failing to connect on a container start-up race — both retriggered with empty commits, neither touched by anything here.

Ready to merge.

CI green at fc18e6e (run #747, 6m3s). The two runs in between (#745, #746) each failed on something unrelated to this branch's diff — `DistantMapClicksBothSurviveJourneyTests` on a modal-overlay pointer-event intercept, then the WebUI-only image probe failing to connect on a container start-up race — both retriggered with empty commits, neither touched by anything here. Ready to merge.
rob left a comment

Verdict: mergeable

Attacked the sentinel construction directly, not the round-trip table. Built a corrected test harness (my first attempt made exactly the mistake this function fixes — wrapping a trailing-newline test value in $(...) at the call site silently ate the newline before pg_quote ever saw it; redone with the value as a literal embedded newline in the script source) and ran it under real busybox ash on alpine:3.23.5: values that are exactly X, XX, empty, a bare newline, ab\nX, ab\nXX, \nX\n, ab\n\n, ab\n\n\n — every one produces the mathematically exact "..."-wrapped, quote-doubled output, verified byte-for-byte against Python's independent computation, not against the shell's own idea of what it should be. Fed those exact byte sequences into NpgsqlConnectionStringBuilder 10.0.3 including a CRLF case: all round-trip exactly, host/port/database/username untouched. %X only strips the single shortest trailing match, which is exactly why a value ending in X or XX survives.

Re-ran the full original adversarial battery (semicolon+host injection, semicolon+SSL Mode injection, bare semicolon, leading '/", embedded =, quote-only value, trailing/only backslash, both quote characters with ;, whitespace-only, quoted numeric port) against this revision specifically rather than assuming it carried over — all still escape correctly. Doubling wasn't disturbed by the sentinel rewrite.

Diffed entrypoint.sh byte-for-byte between 59840f27 and fc18e6e2: the only change anywhere in the file is the pg_quote body and its comment. Nothing else moved, duplicated, or was lost in the rebase. docs/adr/README.md confirmed a clean single-line insertion ahead of task 238's already-merged 0154 row. docs/adr/0153-*.md is byte-identical between the two revisions — the newline fix isn't mentioned there, matching the PR comment's stated reasoning.

Keeping the newline fix out of ADR-0153's Consequences is the right call: it's a bug in an unmerged draft of this PR's own function, found and closed before main ever saw it, not a cost the decision is accepting. Recording it there would make the ADR a changelog of the PR's iteration rather than a record of what was actually decided — inconsistent with how this project's other ADRs use that section (e.g. ADR-0031's own Consequences name only things that remain true after the decision).

Process point, not a blocker: retriggering the two intervening failures with empty commits is fine given they're independently identifiable as pre-existing flakes (a pointer-event-intercept E2E test, a container start-up race) unrelated to this diff, rather than an unexamined retry.

Everything cleared in the previous two rounds still holds and is untouched by this diff.

Verdict: mergeable Attacked the sentinel construction directly, not the round-trip table. Built a corrected test harness (my first attempt made exactly the mistake this function fixes — wrapping a trailing-newline test value in `$(...)` at the call site silently ate the newline before `pg_quote` ever saw it; redone with the value as a literal embedded newline in the script source) and ran it under real busybox `ash` on `alpine:3.23.5`: values that are exactly `X`, `XX`, empty, a bare newline, `ab\nX`, `ab\nXX`, `\nX\n`, `ab\n\n`, `ab\n\n\n` — every one produces the mathematically exact `"..."`-wrapped, quote-doubled output, verified byte-for-byte against Python's independent computation, not against the shell's own idea of what it should be. Fed those exact byte sequences into `NpgsqlConnectionStringBuilder` 10.0.3 including a CRLF case: all round-trip exactly, host/port/database/username untouched. `%X` only strips the single shortest trailing match, which is exactly why a value ending in `X` or `XX` survives. Re-ran the full original adversarial battery (semicolon+host injection, semicolon+SSL Mode injection, bare semicolon, leading `'`/`"`, embedded `=`, quote-only value, trailing/only backslash, both quote characters with `;`, whitespace-only, quoted numeric port) against *this* revision specifically rather than assuming it carried over — all still escape correctly. Doubling wasn't disturbed by the sentinel rewrite. Diffed `entrypoint.sh` byte-for-byte between `59840f27` and `fc18e6e2`: the only change anywhere in the file is the `pg_quote` body and its comment. Nothing else moved, duplicated, or was lost in the rebase. `docs/adr/README.md` confirmed a clean single-line insertion ahead of task 238's already-merged `0154` row. `docs/adr/0153-*.md` is byte-identical between the two revisions — the newline fix isn't mentioned there, matching the PR comment's stated reasoning. Keeping the newline fix out of ADR-0153's Consequences is the right call: it's a bug in an unmerged draft of this PR's own function, found and closed before `main` ever saw it, not a cost the decision is accepting. Recording it there would make the ADR a changelog of the PR's iteration rather than a record of what was actually decided — inconsistent with how this project's other ADRs use that section (e.g. ADR-0031's own Consequences name only things that remain true after the decision). Process point, not a blocker: retriggering the two intervening failures with empty commits is fine given they're independently identifiable as pre-existing flakes (a pointer-event-intercept E2E test, a container start-up race) unrelated to this diff, rather than an unexamined retry. Everything cleared in the previous two rounds still holds and is untouched by this diff.
rob merged commit 1f71c66b2e into main 2026-08-16 18:12:27 +00:00
rob deleted branch feature/task-237-joint-env-config 2026-08-16 18:12:27 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!179
No description provided.