Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,38 @@ jobs:
USE_SINGLE_TRANSACTION=false
EOF

- name: Assert a password of URL-reserved characters connects
env:
PGPASSWORD: postgres
run: |
# The connection details used to be interpolated into a
# postgres://user:password@host:port/db string and parsed back, so
# every RFC 3986 reserved character in a password was read as syntax
# (issue #244). `#`, `/` and `?` failed as "invalid port number"
# before any socket was opened, and `%41` was silently decoded, so
# `pass%41word` authenticated as `passAword`.
#
# Nothing about a schema diff exercises this, so it gets its own step:
# create a role whose password holds every character that misbehaved,
# then dump as that role.
password='p#ss/w?rd%41@x%'
psql -h 127.0.0.1 -U postgres -d a -v ON_ERROR_STOP=1 -c \
"drop role if exists pgc_issue244_role;"
psql -h 127.0.0.1 -U postgres -d a -v ON_ERROR_STOP=1 -c \
"create role pgc_issue244_role login password '${password}';"
if ! ./app/target/release/pgc --command dump \
--server 127.0.0.1 --port 5432 --database a --scheme public \
--output /tmp/issue244.dump \
--user pgc_issue244_role --password "${password}" > /tmp/issue244.log 2>&1; then
echo "ERROR: a password of URL-reserved characters was refused on PG ${{ matrix.pg_version }}"
cat /tmp/issue244.log
psql -h 127.0.0.1 -U postgres -d a -c "drop role if exists pgc_issue244_role;" || true
exit 1
fi
psql -h 127.0.0.1 -U postgres -d a -v ON_ERROR_STOP=1 -c \
"drop role if exists pgc_issue244_role;"
echo "OK: reserved characters in a password survive on PG ${{ matrix.pg_version }}"

- name: Run pgc compare (round 1) — generate diff
run: |
./app/target/release/pgc --config /tmp/pgc.conf
Expand Down
41 changes: 33 additions & 8 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
2026-09-05 v1.0.27
2026-09-06 v1.0.27

Fixes four migrations that did not do what they said:
Fixes four migrations that did not do what they said -
the routine ordering bug behind issue #240, with one
opt-in flag for it, the schema-moved extension of
issue #241, the materialized view rebuilt on an
unrelated column change of issue #242, and the
identity column dropped in the wrong order of issue
#243. Beyond those this release reorganises the test
suite, adds a library target and integration tests,
and documents the crate. The dump gains one optional
field, which older dumps may omit, and the generated
SQL is unchanged for any schema none of the four bugs
touched.
#243 - and one class of password that could not be
used to connect at all (issue #244). Beyond those
this release reorganises the test suite, adds a
library target and integration tests, and documents
the crate. The dump gains one optional field, which
older dumps may omit, and the generated SQL is
unchanged for any schema none of the four migration
bugs touched.

Bug fixes:
- Resolved issue #244 (a password containing
characters that are reserved in a URL could not be
used, and one kind of password was silently
changed). Connection details were interpolated
into a postgres://user:password@host:port/database
string and handed back to sqlx to parse, so every
reserved character in a credential was read as URL
syntax rather than as data. A password containing
#, / or ? ended the authority section early and
the parser eventually read the port out of
something that was not a number, reporting invalid
port number - naming the one part of the
connection that was never wrong, before any socket
was opened. Worse and quieter, a password
containing a valid percent-escape was decoded:
pass%41word was sent as passAword, so the wrong
password was accepted and the real one could not
be used at all. The connection now goes through
PgConnectOptions with the fields set individually,
which sqlx passes to the wire protocol unaltered,
and a port that is not a number is reported as a
port instead of as a failed connection. The masked
connection string in log output is unchanged.
- Resolved issue #243 (a column that stopped being
an identity column produced a migration PostgreSQL
refused to run). One column's ALTER statements
Expand Down
72 changes: 53 additions & 19 deletions app/src/config/dump_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! [`Dump`](crate::dump::core::Dump): the password must never reach a dump file.

use serde::{Deserialize, Serialize};
use sqlx::postgres::{PgConnectOptions, PgSslMode};

/// This is a database dump configuration structure.
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -27,7 +28,33 @@ pub struct DumpConfig {
}

impl DumpConfig {
/// Returns the connection string for the database.
/// Connection options for the database, ready to hand to
/// `PgPoolOptions::connect_with`.
///
/// Deliberately **not** a URL. The credentials go into the fields as they
/// were typed, and sqlx passes them to the PostgreSQL wire protocol
/// unaltered; nothing ever parses them as URL syntax. Building a
/// `postgres://user:password@host:port/db` string and letting sqlx parse it
/// back means every RFC 3986 reserved character in a password is read as
/// syntax rather than data, and that failed three different ways
/// (issue #244, all verified live):
///
/// - `#`, `/` and `?` end the authority section early, so the parser
/// eventually reads the port out of something that is not a number and
/// reports `invalid port number` — naming the one part of the URL that
/// was never wrong;
/// - `%` followed by two hex digits is silently percent-**decoded**, so
/// `pass%41word` authenticates as `passAword`. A password that really
/// contains `%41` cannot be used at all, and the failure blames the
/// credentials rather than pgc;
/// - `@` and a bare `%` happened to survive, which is worse than if they
/// had not: the class of bug looked narrower than it was.
///
/// Built on `new_without_pgpass`, not `new`, because that is what sqlx's
/// own URL parser uses (`PgConnectOptions::parse_from_url`). Starting from
/// `new` instead would newly consult `~/.pgpass` and could silently
/// substitute a password the configuration did not ask for.
///
/// # Examples
///
/// ```
Expand All @@ -37,32 +64,39 @@ impl DumpConfig {
/// host: "db.example".to_string(),
/// port: "5432".to_string(),
/// user: "alice".to_string(),
/// password: "s3cret".to_string(),
/// // Reserved in a URL, ordinary in a password.
/// password: "p#ss/w?rd%41".to_string(),
/// database: "shop".to_string(),
/// scheme: "public".to_string(),
/// ssl: true,
/// file: "dump.from".to_string(),
/// };
///
/// assert_eq!(
/// config.get_connection_string(),
/// "postgres://alice:s3cret@db.example:5432/shop?sslmode=require"
/// );
/// let options = config.get_connect_options().unwrap();
/// assert_eq!(options.get_host(), "db.example");
/// assert_eq!(options.get_port(), 5432);
/// ```
///
/// This string contains the password — use
/// [`get_masked_connection_string`](Self::get_masked_connection_string)
/// for anything that gets printed or logged.
pub fn get_connection_string(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}?sslmode={}",
self.user,
self.password,
self.host,
self.port,
self.database,
if self.ssl { "require" } else { "disable" }
)
/// # Errors
///
/// [`sqlx::Error::Configuration`] when [`port`](Self::port) is not a `u16`.
/// The field is a `String` because it arrives from a config file or a CLI
/// flag, so the parse has to happen somewhere; doing it here means a bad
/// port is reported as a bad port.
pub fn get_connect_options(&self) -> Result<PgConnectOptions, sqlx::Error> {
let port: u16 = self.port.trim().parse().map_err(sqlx::Error::config)?;

Ok(PgConnectOptions::new_without_pgpass()
.host(&self.host)
.port(port)
.username(&self.user)
.password(&self.password)
.database(&self.database)
.ssl_mode(if self.ssl {
PgSslMode::Require
} else {
PgSslMode::Disable
}))
}

/// Returns a masked connection string for the database.
Expand Down
Loading
Loading