From 168b738d084768f0830b751402eab6de6456814b Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Sun, 16 Nov 2025 13:24:38 -0500 Subject: [PATCH 1/8] Default to unix socket if `TEST_DATABASE_URL` has no host --- crates/crates_io_test_db/src/lib.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/crates_io_test_db/src/lib.rs b/crates/crates_io_test_db/src/lib.rs index 447bdb6eaa9..8c2a8b0d034 100644 --- a/crates/crates_io_test_db/src/lib.rs +++ b/crates/crates_io_test_db/src/lib.rs @@ -30,7 +30,17 @@ impl TemplateDatabase { #[instrument] fn new() -> Self { - let base_url: Url = required_var_parsed("TEST_DATABASE_URL").unwrap(); + let mut base_url: Url = required_var_parsed("TEST_DATABASE_URL").unwrap(); + + if base_url.host().is_none() { + if cfg!(unix) { + // Default to a Unix socket if no hostname is provided. + base_url.set_host(Some("%2Frun%2Fpostgresql")).unwrap(); + } else { + // Provide a clear error now rather than when trying to connect. + panic!("No host provided in TEST_DATABASE_URL and unix sockets are not available."); + } + } let prefix = base_url.path().strip_prefix('/'); let prefix = prefix.expect("failed to parse database name").to_string(); From a9d6ca16bf4185d4feab1e74909b4bdb48961d5b Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Sun, 16 Nov 2025 13:11:35 -0500 Subject: [PATCH 2/8] Enable ChoasProxy to connect to unix sockets --- src/tests/util/chaosproxy.rs | 92 ++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/src/tests/util/chaosproxy.rs b/src/tests/util/chaosproxy.rs index cc72e6f5689..9e5a7c1cc0a 100644 --- a/src/tests/util/chaosproxy.rs +++ b/src/tests/util/chaosproxy.rs @@ -1,24 +1,30 @@ -use anyhow::{Context, anyhow}; use std::net::SocketAddr; +use std::str::FromStr as _; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; + +use anyhow::{Context, anyhow}; +use futures_util::FutureExt as _; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +#[cfg(unix)] +use tokio::net::UnixStream; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::broadcast::Sender; +use tokio_postgres::Config; +use tokio_postgres::config::Host; use tracing::{debug, error}; use url::Url; pub(crate) struct ChaosProxy { address: SocketAddr, - backend_address: SocketAddr, + backend_config: Config, break_networking_send: Sender<()>, restore_networking_send: Sender<()>, } impl ChaosProxy { - pub(crate) async fn new(backend_address: SocketAddr) -> anyhow::Result> { - debug!("Creating ChaosProxy for {backend_address}"); + pub(crate) async fn new(backend_config: Config) -> anyhow::Result> { + debug!(?backend_config, "Creating ChaosProxy"); let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; @@ -29,7 +35,7 @@ impl ChaosProxy { let instance = Arc::new(ChaosProxy { address, - backend_address, + backend_config, break_networking_send, restore_networking_send, @@ -47,15 +53,12 @@ impl ChaosProxy { } pub(crate) async fn proxy_database_url(url: &str) -> anyhow::Result<(Arc, String)> { + let backend_config = + Config::from_str(url).context("failed to parse database url as config")?; + let mut db_url = Url::parse(url).context("failed to parse database url")?; - let backend_addr = db_url - .socket_addrs(|| Some(5432)) - .context("could not resolve database url")? - .first() - .copied() - .ok_or_else(|| anyhow!("the database url does not point to any IP"))?; - let instance = ChaosProxy::new(backend_addr).await?; + let instance = ChaosProxy::new(backend_config).await?; db_url .set_ip_host(instance.address.ip()) @@ -118,22 +121,61 @@ impl ChaosProxy { async fn accept_connection(&self, accepted: TcpStream) -> anyhow::Result<()> { let (client_read, client_write) = accepted.into_split(); - let (backend_read, backend_write) = TcpStream::connect(&self.backend_address) - .await? - .into_split(); - let break_networking_send = self.break_networking_send.clone(); + let host = self.backend_config.get_hosts().first().unwrap(); + let port = self.backend_config.get_ports().first().unwrap(); + + let (backend_to_client, client_to_backend) = match &host { + Host::Tcp(hostname) => { + let (backend_read, backend_write) = TcpStream::connect((hostname.as_ref(), *port)) + .await? + .into_split(); + ( + proxy_data( + self.break_networking_send.clone(), + client_read, + backend_write, + ) + .boxed(), + proxy_data( + self.break_networking_send.clone(), + backend_read, + client_write, + ) + .boxed(), + ) + } + #[cfg(not(unix))] + Host::Unix(_) => panic!("Unix sockets not supported on this platform"), + #[cfg(unix)] + Host::Unix(path) => { + let path = path.join(format!(".s.PGSQL.{port}")); + let (backend_read, backend_write) = UnixStream::connect(path).await?.into_split(); + ( + proxy_data( + self.break_networking_send.clone(), + client_read, + backend_write, + ) + .boxed(), + proxy_data( + self.break_networking_send.clone(), + backend_read, + client_write, + ) + .boxed(), + ) + } + }; + tokio::spawn(async move { - if let Err(error) = proxy_data(break_networking_send, client_read, backend_write).await - { + if let Err(error) = backend_to_client.await { error!(%error, "ChaosProxy connection error"); } }); - let break_networking_send = self.break_networking_send.clone(); tokio::spawn(async move { - if let Err(error) = proxy_data(break_networking_send, backend_read, client_write).await - { + if let Err(error) = client_to_backend.await { error!(%error, "ChaosProxy connection error"); } }); @@ -144,8 +186,8 @@ impl ChaosProxy { async fn proxy_data( break_networking_send: Sender<()>, - mut from: OwnedReadHalf, - mut to: OwnedWriteHalf, + mut from: impl AsyncRead + Unpin, + mut to: impl AsyncWrite + Unpin, ) -> anyhow::Result<()> { let mut break_connections_recv = break_networking_send.subscribe(); let mut buf = [0; 1024]; From f8e2f469b0adc8c869bc489fc3fae6d56db20b18 Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Sat, 22 Nov 2025 10:34:33 -0500 Subject: [PATCH 3/8] Drop `cfg`s for non unix platforms Windows support in the backend has been broken for a long time, so we can assume `cfg(unix)`. --- crates/crates_io_test_db/src/lib.rs | 9 ++------- src/tests/util/chaosproxy.rs | 7 +------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/crates_io_test_db/src/lib.rs b/crates/crates_io_test_db/src/lib.rs index 8c2a8b0d034..35cc46c663c 100644 --- a/crates/crates_io_test_db/src/lib.rs +++ b/crates/crates_io_test_db/src/lib.rs @@ -33,13 +33,8 @@ impl TemplateDatabase { let mut base_url: Url = required_var_parsed("TEST_DATABASE_URL").unwrap(); if base_url.host().is_none() { - if cfg!(unix) { - // Default to a Unix socket if no hostname is provided. - base_url.set_host(Some("%2Frun%2Fpostgresql")).unwrap(); - } else { - // Provide a clear error now rather than when trying to connect. - panic!("No host provided in TEST_DATABASE_URL and unix sockets are not available."); - } + // Default to a Unix socket if no hostname is provided. + base_url.set_host(Some("%2Frun%2Fpostgresql")).unwrap(); } let prefix = base_url.path().strip_prefix('/'); diff --git a/src/tests/util/chaosproxy.rs b/src/tests/util/chaosproxy.rs index 9e5a7c1cc0a..e4fcd02e96f 100644 --- a/src/tests/util/chaosproxy.rs +++ b/src/tests/util/chaosproxy.rs @@ -5,9 +5,7 @@ use std::sync::Arc; use anyhow::{Context, anyhow}; use futures_util::FutureExt as _; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -#[cfg(unix)] -use tokio::net::UnixStream; -use tokio::net::{TcpListener, TcpStream}; +use tokio::net::{TcpListener, TcpStream, UnixStream}; use tokio::sync::broadcast::Sender; use tokio_postgres::Config; use tokio_postgres::config::Host; @@ -145,9 +143,6 @@ impl ChaosProxy { .boxed(), ) } - #[cfg(not(unix))] - Host::Unix(_) => panic!("Unix sockets not supported on this platform"), - #[cfg(unix)] Host::Unix(path) => { let path = path.join(format!(".s.PGSQL.{port}")); let (backend_read, backend_write) = UnixStream::connect(path).await?.into_split(); From 083cc440d9823bdc3b1c227ba87819b82ba77a16 Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Tue, 2 Dec 2025 20:21:13 -0500 Subject: [PATCH 4/8] ChaosProxy: accept then immediately drop connection This slightly changes the low-level TCP behavior. The previous implementation dropped the listener which results in an immediate TCP RST in response to the client's SYN packet. In the new implementation the SYN handshake completes before a RST is sent. This prepares for the next commit, which will change the TCP connection to a Unix socket. Unfortunately, dropping the listener, manually deleting the socket file, and then recreating the socket does not result in a healthy connection. This commit splits off the slight semantic change while still passing the tests over TCP sockets. I investigated taking this further such that the connection is accepted and stalled indefinitely (by pushing the stream to a Vec that is owned for the duration of the test). This resulted in the chaos proxy tests hanging until the timeout was hit. It also changed the status code observed in the test to a 408 Request Timeout. (Most of these tests currently return 503 Service Unavailable but `fallback_to_replica_returns_user_info` is expected to return 200 OK.) Longer term, maybe it would make sense to make the behvior more consistent and to add test coverage for both failure modes. --- src/tests/util/chaosproxy.rs | 46 ++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/tests/util/chaosproxy.rs b/src/tests/util/chaosproxy.rs index e4fcd02e96f..26b5d2d9fe3 100644 --- a/src/tests/util/chaosproxy.rs +++ b/src/tests/util/chaosproxy.rs @@ -83,37 +83,33 @@ impl ChaosProxy { .context("Failed to send the restore_networking message") } - async fn server_loop(&self, initial_listener: TcpListener) -> anyhow::Result<()> { - let mut listener = Some(initial_listener); - + async fn server_loop(&self, listener: TcpListener) -> anyhow::Result<()> { + let mut is_broken = false; let mut break_networking_recv = self.break_networking_send.subscribe(); let mut restore_networking_recv = self.restore_networking_send.subscribe(); loop { - if let Some(l) = &listener { - debug!("ChaosProxy waiting for connections"); - tokio::select! { - accepted = l.accept() => { - let (stream, address ) = accepted?; + debug!("ChaosProxy waiting for connections"); + tokio::select! { + accepted = listener.accept() => { + let (stream, address) = accepted?; + if is_broken { + debug!("ChaosProxy dropped connection from {address}"); + } else { debug!("ChaosProxy accepted connection from {address}"); self.accept_connection(stream).await?; - }, - - _ = break_networking_recv.recv() => { - debug!("ChaosProxy breaking networking"); - - // Setting the listener to `None` results in the listener being dropped, - // which closes the network port. A new listener will be established when - // networking is restored. - listener = None; - }, - }; - } else { - debug!("ChaosProxy networking is broken, waiting for restore signal"); - let _ = restore_networking_recv.recv().await; - debug!("ChaosProxy restoring networking"); - listener = Some(TcpListener::bind(self.address).await?); - } + } + }, + + _ = break_networking_recv.recv(), if !is_broken => { + debug!("ChaosProxy breaking networking"); + is_broken = true; + }, + _ = restore_networking_recv.recv(), if is_broken =>{ + debug!("ChaosProxy restoring networking"); + is_broken = false; + }, + }; } } From 3b3a4a6805daf304eee499928ac578fcbb95f098 Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Tue, 2 Dec 2025 21:49:06 -0500 Subject: [PATCH 5/8] ChaosProxy: Proxy over Unix socket instead of TCP Each proxy now creates a temporary directory containing a Unix socket. Connections made to the Unix socket are forward to the database backend over whichever method is configured via `TEST_DATABASE_URL`. The reason for this change is that otherwise if `TEST_DATABASE_URL` was pointed to a Unix socket then while running tests another user on the localhost might be able to connect to the test database without requiring credentials. It is unlikely that this is a relevant threat model for most developers of crates.io, however it still seems best to not risk ever creating a Postgres TCP -> Unix socket proxy. The downside of this change is that the test configuration no longer matches the TCP environment used in production. An alternative would be to fail the test if a bad configuration was requested. Another alternative is to duplicate the proxy logic to support both socket types. This option was explored, however the resulting code duplication did not seem to be worth the effort. --- src/tests/util/chaosproxy.rs | 46 ++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/tests/util/chaosproxy.rs b/src/tests/util/chaosproxy.rs index 26b5d2d9fe3..e17148b8347 100644 --- a/src/tests/util/chaosproxy.rs +++ b/src/tests/util/chaosproxy.rs @@ -1,11 +1,13 @@ -use std::net::SocketAddr; +use std::fs::Permissions; +use std::os::unix::fs::PermissionsExt as _; use std::str::FromStr as _; use std::sync::Arc; use anyhow::{Context, anyhow}; use futures_util::FutureExt as _; +use tempfile::TempDir; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream, UnixStream}; +use tokio::net::{TcpStream, UnixListener, UnixStream}; use tokio::sync::broadcast::Sender; use tokio_postgres::Config; use tokio_postgres::config::Host; @@ -13,9 +15,8 @@ use tracing::{debug, error}; use url::Url; pub(crate) struct ChaosProxy { - address: SocketAddr, + socket_dir: TempDir, backend_config: Config, - break_networking_send: Sender<()>, restore_networking_send: Sender<()>, } @@ -24,17 +25,20 @@ impl ChaosProxy { pub(crate) async fn new(backend_config: Config) -> anyhow::Result> { debug!(?backend_config, "Creating ChaosProxy"); - let listener = TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - debug!("ChaosProxy listening on {address}"); + let directory_permissions = Permissions::from_mode(0o700); + let socket_dir = tempfile::Builder::new() + .permissions(directory_permissions) + .tempdir()?; + let socket_path = socket_dir.path().join(".s.PGSQL.5432"); + + let listener = UnixListener::bind(&socket_path)?; let (break_networking_send, _) = tokio::sync::broadcast::channel(16); let (restore_networking_send, _) = tokio::sync::broadcast::channel(16); - let instance = Arc::new(ChaosProxy { - address, + let instance = Arc::new(Self { + socket_dir, backend_config, - break_networking_send, restore_networking_send, }); @@ -58,13 +62,15 @@ impl ChaosProxy { let instance = ChaosProxy::new(backend_config).await?; + let host = instance + .socket_dir + .path() + .to_str() + .unwrap() + .replace("/", "%2F"); db_url - .set_ip_host(instance.address.ip()) - .map_err(|_| anyhow!("Failed to set IP host on the URL"))?; - - db_url - .set_port(Some(instance.address.port())) - .map_err(|_| anyhow!("Failed to set post on the URL"))?; + .set_host(Some(&host)) + .map_err(|e| anyhow!("Failed to set socket host on the URL: {e}"))?; debug!("ChaosProxy database URL: {db_url}"); @@ -83,7 +89,7 @@ impl ChaosProxy { .context("Failed to send the restore_networking message") } - async fn server_loop(&self, listener: TcpListener) -> anyhow::Result<()> { + async fn server_loop(&self, listener: UnixListener) -> anyhow::Result<()> { let mut is_broken = false; let mut break_networking_recv = self.break_networking_send.subscribe(); let mut restore_networking_recv = self.restore_networking_send.subscribe(); @@ -94,9 +100,9 @@ impl ChaosProxy { accepted = listener.accept() => { let (stream, address) = accepted?; if is_broken { - debug!("ChaosProxy dropped connection from {address}"); + debug!("ChaosProxy dropped connection from {address:?}"); } else { - debug!("ChaosProxy accepted connection from {address}"); + debug!("ChaosProxy accepted connection from {address:?}"); self.accept_connection(stream).await?; } }, @@ -113,7 +119,7 @@ impl ChaosProxy { } } - async fn accept_connection(&self, accepted: TcpStream) -> anyhow::Result<()> { + async fn accept_connection(&self, accepted: UnixStream) -> anyhow::Result<()> { let (client_read, client_write) = accepted.into_split(); let host = self.backend_config.get_hosts().first().unwrap(); From 7955aad8d6fff727cb07618d74c80b0348e18b39 Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Tue, 2 Dec 2025 21:51:10 -0500 Subject: [PATCH 6/8] Default sample env to connect to DB via Unix socket --- .env.sample | 12 +++++++----- docs/CONTRIBUTING.md | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.env.sample b/.env.sample index 1f6b6adb0e1..8e8a32b9360 100644 --- a/.env.sample +++ b/.env.sample @@ -1,7 +1,10 @@ # Location of the *postgres* database. For example, if you have created a # blank database locally named `cargo_registry`, this would be -# `postgres://postgres@localhost/cargo_registry`. -export DATABASE_URL= +# `postgres://db_username:db_password@localhost/cargo_registry`. +# On Unix systems, a shorthand of `postgres:///cargo_registry` can be used +# to connect via a local Unix socket, with a db_username equal to your Unix +# account name and does not require a password. +export DATABASE_URL=postgres:///cargo_registry # Allowed origins - any origins for which you want to allow browser # access to authenticated endpoints. @@ -18,9 +21,8 @@ export SESSION_KEY=badkeyabcdefghijklmnopqrstuvwxyzabcdef # If you will be running the tests, set this to another database that you # have created. For example, if your test database is named # `cargo_registry_test`, this would look something like -# `postgres://postgres@localhost/cargo_registry_test` -# If you don't plan on running the tests, you can leave this blank. -export TEST_DATABASE_URL= +# `postgres://db_username:db_password/cargo_registry_test` +export TEST_DATABASE_URL=postgres:///cargo_registry_test # Credentials for AWS. # export AWS_ACCESS_KEY= diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f24469d0c8e..8974d9030ce 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -293,11 +293,13 @@ linking with `cc` failed: exit code: 1``, you're probably missing some ##### Environment variables -Copy the `.env.sample` file to `.env`. Modify the settings as appropriate; -minimally you'll need to specify or modify the value of the `DATABASE_URL` var. -Try using `postgres://postgres@localhost/cargo_registry` first. +Copy the `.env.sample` file to `.env` and then modify `.env` as appropriate. On +Unix systems, the default configuration will use a local Unix socket which does +not require setting a password for the database user. -> If that doesn't work, change this by filling in this template with the +On other platforms, or if connecting to your database over IP: + +> Change this by filling in this template with the > appropriate values where there are `[]`s: > > ```text @@ -432,7 +434,7 @@ In your `.env` file, set `TEST_DATABASE_URL` to a value that's the same as connection will be used to create new databases for the tests, with names prefixed with the database name from `TEST_DATABASE_URL`. -Example: `postgres://postgres@localhost/cargo_registry_test`. +Example: `postgres:///cargo_registry_test`. Create the test database by running: From 751c2001b9d7cc23628c63dfeb9e57b59fcc32dc Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Wed, 3 Dec 2025 21:27:39 -0500 Subject: [PATCH 7/8] Correctly handle `host=` query param in `TEST_DATABASE_URL` Any `host=` parameters that are provided should be removed. Otherwise, the backend will route around our `ChoasProxy` after it breaks the connection. --- crates/crates_io_test_db/src/lib.rs | 2 +- src/tests/util/chaosproxy.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/crates_io_test_db/src/lib.rs b/crates/crates_io_test_db/src/lib.rs index 35cc46c663c..662aa2bf6f4 100644 --- a/crates/crates_io_test_db/src/lib.rs +++ b/crates/crates_io_test_db/src/lib.rs @@ -32,7 +32,7 @@ impl TemplateDatabase { fn new() -> Self { let mut base_url: Url = required_var_parsed("TEST_DATABASE_URL").unwrap(); - if base_url.host().is_none() { + if base_url.host().is_none() && !base_url.query_pairs().any(|(key, _)| key == "host") { // Default to a Unix socket if no hostname is provided. base_url.set_host(Some("%2Frun%2Fpostgresql")).unwrap(); } diff --git a/src/tests/util/chaosproxy.rs b/src/tests/util/chaosproxy.rs index e17148b8347..f9b1f4fa6fb 100644 --- a/src/tests/util/chaosproxy.rs +++ b/src/tests/util/chaosproxy.rs @@ -72,6 +72,13 @@ impl ChaosProxy { .set_host(Some(&host)) .map_err(|e| anyhow!("Failed to set socket host on the URL: {e}"))?; + // Drop any `host=` query params as that would route around our proxy. + let db_url_clone = db_url.clone(); + db_url + .query_pairs_mut() + .clear() + .extend_pairs(db_url_clone.query_pairs().filter(|(key, _)| key != "host")); + debug!("ChaosProxy database URL: {db_url}"); Ok((instance, db_url.into())) @@ -123,7 +130,7 @@ impl ChaosProxy { let (client_read, client_write) = accepted.into_split(); let host = self.backend_config.get_hosts().first().unwrap(); - let port = self.backend_config.get_ports().first().unwrap(); + let port = self.backend_config.get_ports().first().unwrap_or(&5432); let (backend_to_client, client_to_backend) = match &host { Host::Tcp(hostname) => { From 29fbf380698eadb257418b786c24c336eabedaa4 Mon Sep 17 00:00:00 2001 From: Justin Geibel Date: Wed, 3 Dec 2025 21:41:38 -0500 Subject: [PATCH 8/8] Default to a Unix socket if no database host is provided --- src/db.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/db.rs b/src/db.rs index 94ccd8e52e2..d1ad3c73996 100644 --- a/src/db.rs +++ b/src/db.rs @@ -26,6 +26,11 @@ pub async fn oneoff_connection() -> anyhow::Result { pub fn connection_url(config: &config::DbPoolConfig) -> String { let mut url = Url::parse(config.url.expose_secret()).expect("Invalid database URL"); + // Support `postgres:///db_name` shorthand for easier local development. + if url.host().is_none() { + maybe_append_url_param(&mut url, "host", "/run/postgresql"); + } + if config.enforce_tls { maybe_append_url_param(&mut url, "sslmode", "require"); }