#![deny(unsafe_code)]
mod arguments;
use std::error::Error as StdError;
use std::ffi::OsString;
use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use arguments::{validate_pg_dump_arguments, validate_psql_arguments};
pub const PRODUCT: &str = "oliphaunt-tools";
pub const KIND: &str = "native-tools";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PgDumpOptions {
args: Vec<String>,
}
impl PgDumpOptions {
pub fn new() -> Self {
Self::default()
}
pub fn arg(mut self, argument: impl Into<String>) -> Self {
self.args.push(argument.into());
self
}
pub fn args(mut self, arguments: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args.extend(arguments.into_iter().map(Into::into));
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PsqlOptions {
args: Vec<String>,
input: Option<PsqlInput>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PsqlInput {
Command(String),
Script(String),
}
impl PsqlOptions {
pub fn new() -> Self {
Self::default()
}
pub fn arg(mut self, argument: impl Into<String>) -> Self {
self.args.push(argument.into());
self
}
pub fn args(mut self, arguments: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args.extend(arguments.into_iter().map(Into::into));
self
}
pub fn command(mut self, sql: impl Into<String>) -> Self {
self.input = Some(PsqlInput::Command(sql.into()));
self
}
pub fn script(mut self, sql: impl Into<String>) -> Self {
self.input = Some(PsqlInput::Script(sql.into()));
self
}
}
#[derive(Debug)]
pub struct PostgresToolError {
pub tool: &'static str,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
source: Option<std::io::Error>,
}
impl fmt::Display for PostgresToolError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(source) = &self.source {
return write!(formatter, "could not run {}: {source}", self.tool);
}
write!(
formatter,
"{} exited with status {}{}",
self.tool,
self.exit_code
.map_or_else(|| "unknown".to_owned(), |status| status.to_string()),
if self.stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", self.stderr.trim())
}
)
}
}
impl StdError for PostgresToolError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.source
.as_ref()
.map(|source| source as &(dyn StdError + 'static))
}
}
pub fn pg_dump(
connection_string: &str,
options: PgDumpOptions,
) -> Result<String, PostgresToolError> {
validate_connection_string("pg_dump", connection_string)?;
validate_pg_dump_arguments(&options.args)
.map_err(|message| configuration_error("pg_dump", &message))?;
let mut arguments = options
.args
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
arguments.push(OsString::from("--encoding=UTF8"));
arguments.push(OsString::from("--no-password"));
arguments.push(OsString::from(format!("--dbname={connection_string}")));
run_tool("pg_dump", arguments, None)
}
pub fn psql(connection_string: &str, options: PsqlOptions) -> Result<String, PostgresToolError> {
validate_connection_string("psql", connection_string)?;
validate_psql_arguments(&options.args)
.map_err(|message| configuration_error("psql", &message))?;
if options.input.is_none() && options.args.is_empty() {
return Err(configuration_error(
"psql",
"psql requires command(), script(), or a non-input argument",
));
}
match options.input.as_ref() {
Some(PsqlInput::Command(command)) => validate_text("psql", "command", command)?,
Some(PsqlInput::Script(script)) => validate_text("psql", "script", script)?,
None => {}
}
let (arguments, stdin) = psql_invocation(connection_string, options);
run_tool("psql", arguments, stdin)
}
fn psql_invocation(
connection_string: &str,
options: PsqlOptions,
) -> (Vec<OsString>, Option<Vec<u8>>) {
let mut arguments = options
.args
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
arguments.extend([
OsString::from("--no-psqlrc"),
OsString::from("--no-password"),
OsString::from("--set=ON_ERROR_STOP=1"),
OsString::from(format!("--dbname={connection_string}")),
]);
let stdin = match options.input {
Some(PsqlInput::Command(command)) => {
arguments.push(OsString::from("--command"));
arguments.push(OsString::from(command));
None
}
Some(PsqlInput::Script(script)) => {
arguments.push(OsString::from("--file=-"));
Some(script.into_bytes())
}
None => None,
};
(arguments, stdin)
}
fn run_tool(
tool: &'static str,
arguments: Vec<OsString>,
stdin: Option<Vec<u8>>,
) -> Result<String, PostgresToolError> {
let executable = resolve_tool(tool)?;
let mut command = Command::new(&executable);
command
.args(arguments)
.env("PGCLIENTENCODING", "UTF8")
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_runtime_environment(&mut command, &executable);
let mut child = command.spawn().map_err(|source| PostgresToolError {
tool,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
source: Some(source),
})?;
let input_writer = stdin.and_then(|input| {
child
.stdin
.take()
.map(|mut writer| thread::spawn(move || writer.write_all(&input)))
});
let output = child
.wait_with_output()
.map_err(|source| PostgresToolError {
tool,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
source: Some(source),
})?;
let input_failure = input_writer.and_then(|writer| match writer.join() {
Ok(Ok(())) => None,
Ok(Err(error)) => Some(error),
Err(_) => Some(std::io::Error::other("psql stdin writer panicked")),
});
if !output.status.success() {
return Err(PostgresToolError {
tool,
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
source: None,
});
}
if let Some(source) = input_failure {
return Err(PostgresToolError {
tool,
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
source: Some(source),
});
}
String::from_utf8(output.stdout).map_err(|error| PostgresToolError {
tool,
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(error.as_bytes()).into_owned(),
stderr: format!(
"{}{} produced non-UTF-8 output: {error}",
String::from_utf8_lossy(&output.stderr),
tool
),
source: None,
})
}
fn resolve_tool(tool: &'static str) -> Result<PathBuf, PostgresToolError> {
let executable = if cfg!(windows) {
format!("{tool}.exe")
} else {
tool.to_owned()
};
let mut roots = Vec::new();
if let Some(directory) = std::env::var_os("OLIPHAUNT_TOOLS_DIR") {
roots.push(PathBuf::from(directory));
}
if let Some(directory) = option_env!("OLIPHAUNT_PACKAGED_TOOLS_DIR") {
roots.push(PathBuf::from(directory));
}
if let Some(directory) = option_env!("OLIPHAUNT_RESOURCES_DIR") {
roots.push(PathBuf::from(directory).join("native-tools/oliphaunt-tools/runtime"));
}
if let Ok(current) = std::env::current_exe()
&& let Some(directory) = current.parent()
{
roots.push(directory.join("oliphaunt-tools/runtime"));
roots.push(directory.join("runtime"));
}
for root in roots {
let candidate = root.join("bin").join(&executable);
if candidate.is_file() {
return Ok(candidate);
}
}
Err(configuration_error(
tool,
&format!(
"could not locate packaged {tool}; add the oliphaunt-tools artifact facade or set OLIPHAUNT_TOOLS_DIR"
),
))
}
fn configure_runtime_environment(command: &mut Command, executable: &Path) {
let Some(runtime) = executable.parent().and_then(Path::parent) else {
return;
};
let library = runtime.join("lib");
prepend_environment_path(command, "PATH", executable.parent().unwrap_or(runtime));
if cfg!(target_os = "macos") {
prepend_environment_path(command, "DYLD_LIBRARY_PATH", &library);
} else if cfg!(unix) {
prepend_environment_path(command, "LD_LIBRARY_PATH", &library);
}
let icu = runtime.join("share/icu");
if icu.is_dir() {
command.env("ICU_DATA", icu);
}
}
fn prepend_environment_path(command: &mut Command, name: &str, value: &Path) {
let mut paths = vec![value.to_path_buf()];
if let Some(existing) = std::env::var_os(name) {
paths.extend(std::env::split_paths(&existing));
}
if let Ok(joined) = std::env::join_paths(paths) {
command.env(name, joined);
}
}
fn validate_connection_string(tool: &'static str, value: &str) -> Result<(), PostgresToolError> {
if value.trim().is_empty() || value.as_bytes().contains(&0) {
return Err(configuration_error(
tool,
"connection string must not be empty or contain NUL bytes",
));
}
Ok(())
}
fn validate_text(tool: &'static str, label: &str, value: &str) -> Result<(), PostgresToolError> {
if value.as_bytes().contains(&0) {
return Err(configuration_error(
tool,
&format!("{label} must not contain NUL bytes"),
));
}
Ok(())
}
fn configuration_error(tool: &'static str, message: &str) -> PostgresToolError {
PostgresToolError {
tool,
exit_code: None,
stdout: String::new(),
stderr: message.to_owned(),
source: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn psql_scripts_explicitly_read_standard_input() {
let (script, stdin) = psql_invocation(
"postgresql://localhost/postgres",
PsqlOptions::new().script("SELECT 1;"),
);
assert!(script.iter().any(|argument| argument == "--file=-"));
assert_eq!(stdin.as_deref(), Some(b"SELECT 1;".as_slice()));
let (command, stdin) = psql_invocation(
"postgresql://localhost/postgres",
PsqlOptions::new().command("SELECT 1"),
);
assert!(!command.iter().any(|argument| argument == "--file=-"));
assert!(
command
.windows(2)
.any(|arguments| arguments == ["--command", "SELECT 1"])
);
assert!(stdin.is_none());
}
}
#[cfg(not(any(all(target_os = "linux", target_arch = "aarch64", target_env = "gnu"), all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"), all(target_os = "macos", target_arch = "aarch64"), all(target_os = "windows", target_arch = "x86_64", target_env = "msvc"))))]
compile_error!("oliphaunt-tools supports only linux-arm64-gnu, linux-x64-gnu, macos-arm64, windows-x64-msvc; use one of these declared native targets; this package has no portable fallback.");