From c1e2f6dc3f191eaa4680db89b8fb77005187faa8 Mon Sep 17 00:00:00 2001 From: hiroki-abe-58 Date: Tue, 27 Oct 2026 21:08:40 +0900 Subject: [PATCH] Refuse mixed-case schema names in with_schema `with_schema` puts the name into `search_path` unquoted, where PostgreSQL folds it to lowercase, but `install_schema` creates the schema quoted, as written. With `with_schema(url, "Board")`, `install_schema` creates "Board", the search path names `board`, and the next CREATE TABLE fails with "no schema has been selected to create in". Refuse names with uppercase letters up front; every caller in the repository already uses lowercase. Co-Authored-By: Claude Opus 5.5 --- crates/h5i-app-pg/src/lib.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/h5i-app-pg/src/lib.rs b/crates/h5i-app-pg/src/lib.rs index b2fb24a80..c01b78b8f 100644 --- a/crates/h5i-app-pg/src/lib.rs +++ b/crates/h5i-app-pg/src/lib.rs @@ -344,8 +344,12 @@ pub struct Engine> { /// `url` with the app's tables in PostgreSQL schema `schema`, so apps sharing a /// database share no tables. Pass the same schema in `lockdown`'s admin URL. +/// The name must be lowercase: `search_path` folds an unquoted name to +/// lowercase, while `install_schema` creates the schema as written. pub fn with_schema(url: &str, schema: &str) -> Result { - roles::ident(schema)?; + if schema.is_empty() || !schema.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') { + return Err(DbError::Decode(format!("schema name {schema:?} must be [a-z0-9_]+"))); + } let sep = if url.contains('?') { '&' } else { '?' }; Ok(format!("{url}{sep}options=-c%20search_path%3D{schema}")) } @@ -769,3 +773,32 @@ mod lock_tests { assert_ne!(Lock::key("article", b"x"), Lock::key("articlex", b"")); } } + +#[cfg(test)] +mod schema_tests { + use super::*; + + #[test] + fn with_schema_sets_the_search_path() { + let url = "postgres://u@localhost/db"; + assert_eq!(with_schema(url, "board").unwrap(), "postgres://u@localhost/db?options=-c%20search_path%3Dboard"); + assert_eq!(with_schema("postgres://localhost/db?sslmode=disable", "app_2").unwrap(), + "postgres://localhost/db?sslmode=disable&options=-c%20search_path%3Dapp_2"); + } + + #[test] + fn with_schema_refuses_names_postgres_would_fold() { + // `search_path=Board` names schema `board`, but `install_schema` would + // create `"Board"` and then fail with "no schema has been selected". + for bad in ["Board", "APP", "myApp"] { + assert!(with_schema("postgres://localhost/db", bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn with_schema_refuses_other_names() { + for bad in ["a b", "a;b", "a-b", ""] { + assert!(with_schema("postgres://localhost/db", bad).is_err(), "{bad:?}"); + } + } +}