Merge commit from fork

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-10-23 18:49:17 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 8af484292f
commit 812ff6ea5e
13 changed files with 105 additions and 299 deletions
+13 -150
View File
@@ -3366,10 +3366,6 @@ pub(crate) async fn resolve_pg_source_checked_with_kind(
let pg: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
// Checked here rather than at each caller: every source and target of a dump, restore or copy
// comes through this function, and `validate_dbname` only ever reaches a target. The check is
// deliberately the narrow one — see `validate_dbname_not_option_like`.
windmill_common::validate_dbname_not_option_like(&pg.dbname)?;
Ok((pg, managed_kind))
}
@@ -3459,20 +3455,21 @@ pub(crate) struct PgDumpOptions<'a> {
pub(crate) no_acl: bool,
}
/// A pg_dump invocation against `pg_db` writing to `dump_path`, carrying the connection settings
/// the CLI reads from the environment. The returned file, when there is one, holds the root
/// certificate the command points at and must outlive it.
fn pg_dump_command(
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
pub(crate) async fn pg_dump_database(
pg_db: &PgDatabase,
opts: PgDumpOptions<'_>,
dump_path: &std::path::Path,
) -> Result<(tokio::process::Command, Option<DumpFile>)> {
) -> Result<DumpFile> {
let dump_file = DumpFile::new()?;
let host = &pg_db.host;
let port = pg_db.port.unwrap_or(5432).to_string();
let user = pg_db.login_name();
let dbname = &pg_db.dbname;
let mut cmd = tokio::process::Command::new("pg_dump");
cmd.arg("--format=plain").arg("--file").arg(dump_path);
cmd.arg("--format=plain").arg("--file").arg(&dump_file.path);
if opts.schema_only {
cmd.arg("--schema-only");
}
@@ -3490,31 +3487,13 @@ fn pg_dump_command(
.arg("--port")
.arg(&port)
.arg("--username")
.arg(user);
// The name comes from a resource, so it never goes on the command line: as the positional
// argument a name starting with `-` is read as an option of pg_dump (`--file`,
// `--restrict-key`), and as `--dbname` a name holding `=` is expanded as a libpq connection
// string overriding the host, port and user set above. libpq takes the environment variable
// as a plain database name, neither parsed as an option nor expanded.
cmd.env("PGDATABASE", &pg_db.dbname);
.arg(user)
.arg(dbname);
if let Some(ref password) = pg_db.password {
cmd.env("PGPASSWORD", password);
}
let root_cert = apply_pg_tls_env(&mut cmd, pg_db)?;
Ok((cmd, root_cert))
}
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
pub(crate) async fn pg_dump_database(
pg_db: &PgDatabase,
opts: PgDumpOptions<'_>,
) -> Result<DumpFile> {
let dump_file = DumpFile::new()?;
let (mut cmd, _root_cert) = pg_dump_command(pg_db, opts, &dump_file.path)?;
let _root_cert = apply_pg_tls_env(&mut cmd, pg_db)?;
let output = cmd
.output()
@@ -3640,14 +3619,12 @@ fn psql_command(pg_db: &PgDatabase) -> Result<(tokio::process::Command, Option<D
.arg(pg_db.port.unwrap_or(5432).to_string())
.arg("--username")
.arg(pg_db.login_name())
.arg("--dbname")
.arg(&pg_db.dbname)
.arg("--no-psqlrc")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
// Not `--dbname`, which expands a name holding `=` as a libpq connection string overriding
// the host, port and user set above. See `pg_dump_command`.
cmd.env("PGDATABASE", &pg_db.dbname);
if let Some(ref password) = pg_db.password {
cmd.env("PGPASSWORD", password);
}
@@ -3738,97 +3715,6 @@ mod pg_tls_env_tests {
}
}
/// The database name of a resource reaches `pg_dump` and `psql` through `PGDATABASE`, never in
/// their argument vector, so neither an option (`--file=`, `--restrict-key=`) nor a libpq
/// connection string (`host=... port=...`) in that field is parsed as one.
#[cfg(test)]
mod pg_dbname_env_tests {
use super::{pg_dump_command, pg_dump_database, psql_command, DumpFile, PgDumpOptions};
use windmill_common::PgDatabase;
/// Nothing listens on that port.
fn pg_db(dbname: &str) -> PgDatabase {
PgDatabase {
host: "127.0.0.1".to_string(),
user: None,
password: None,
port: Some(1),
sslmode: None,
dbname: dbname.to_string(),
root_certificate_pem: None,
accept_invalid_certs: None,
use_iam_auth: None,
region: None,
options: None,
}
}
fn pgdatabase_of(cmd: &std::process::Command) -> Option<std::ffi::OsString> {
cmd.get_envs()
.find(|(k, _)| *k == "PGDATABASE")
.and_then(|(_, v)| v.map(|v| v.to_os_string()))
}
#[test]
fn pg_dump_takes_the_dbname_from_the_environment() {
let dump_file = DumpFile::new().unwrap();
let name = "--file=/tmp/written_by_the_caller";
let (cmd, _root_cert) =
pg_dump_command(&pg_db(name), PgDumpOptions::default(), &dump_file.path).unwrap();
let cmd = cmd.as_std();
assert!(
cmd.get_args().all(|arg| arg != name),
"the dbname is still in pg_dump's argument vector"
);
assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref()));
// The only `--file` pg_dump is given is still Windmill's own.
assert_eq!(
cmd.get_args()
.filter(|arg| arg.to_string_lossy().starts_with("--file"))
.count(),
1
);
}
#[test]
fn psql_takes_the_dbname_from_the_environment() {
let name = "host=elsewhere";
let (cmd, _root_cert) = psql_command(&pg_db(name)).unwrap();
let cmd = cmd.as_std();
assert!(
cmd.get_args().all(|arg| arg != name),
"the dbname is still in psql's argument vector"
);
assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref()));
}
/// The same, observed on the real binary: an option-shaped name must reach the connection
/// attempt rather than pg_dump's option parser. Skipped where pg_dump is not installed —
/// which is why the assertion also requires that pg_dump ran at all (our `pg_dump failed:`
/// prefix) instead of only that the option message is absent.
#[tokio::test]
async fn pg_dump_does_not_read_an_option_shaped_dbname_as_an_option() {
if std::process::Command::new("pg_dump")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipped: pg_dump is not installed");
return;
}
let Err(err) = pg_dump_database(&pg_db("--no-such-option"), PgDumpOptions::default()).await
else {
panic!("pg_dump reached a server on 127.0.0.1:1");
};
let err = err.to_string();
assert!(
err.contains("pg_dump failed:"),
"pg_dump did not run: {err}"
);
assert!(!err.contains("unrecognized option"), "{err}");
}
}
/// GUC names the server backing `pg_db` knows about.
///
/// Asked through psql rather than a tokio-postgres connection so the lookup reaches
@@ -4175,25 +4061,6 @@ fn ensure_copied_without_roles(governing: &GoverningDatatable) -> Result<()> {
Ok(())
}
/// Gate for the endpoints that make the API server run `pg_dump`/`psql` against a database a
/// request names.
///
/// Those two processes are children of the API server, outside any job sandbox and holding its
/// environment, and the dump they exchange is a `psql` script assembled from strings the source
/// server returns — so reaching them is close enough to running code on the server that the role
/// which may not run code at all must not reach them either. `code` is the capability the data
/// table migration endpoints next door already ask for (`datatable_migrations::ensure_not_operator`),
/// and not admin: the database manager offers an export and an import of a user's own Postgres
/// resource to every author, who can run the same `pg_dump` in a job anyway.
async fn ensure_can_dump_databases(
db: &DB,
w_id: &str,
authed: &ApiAuthed,
action: &str,
) -> Result<()> {
windmill_common::workspaces::check_role_can_code(db, w_id, authed, action).await
}
/// Import (pg_dump/pg_import) from source to target
async fn import_pg_database(
authed: ApiAuthed,
@@ -4202,8 +4069,6 @@ async fn import_pg_database(
Path(w_id): Path<String>,
Json(req): Json<ImportPgDatabaseRequest>,
) -> Result<String> {
ensure_can_dump_databases(&db, &w_id, &authed, "import a database").await?;
if req.fork_behavior == DataTableForkBehavior::KeepOriginal {
return Ok("No action needed for KeepOriginal behavior".to_string());
}
@@ -4320,8 +4185,6 @@ async fn export_pg_schema(
Path(w_id): Path<String>,
Json(req): Json<ExportPgSchemaRequest>,
) -> Result<String> {
ensure_can_dump_databases(&db, &w_id, &authed, "export a database schema").await?;
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
let dump_file = pg_dump_database(
&pg,