use crate::engines::{self, Conn, Engine};
use crate::ini;
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, PartialEq)]
pub enum Source {
Pgpass(usize),
MyCnf(String),
}
#[derive(Clone)]
pub struct Cred {
pub engine: Engine,
pub host: String,
pub port: String,
pub database: String,
pub user: String,
pub source: Source,
}
impl Cred {
pub fn describe(&self) -> String {
format!(
"{:<9} {:<28} {:<14} {}",
self.engine.label(),
format!("{}:{}", self.host, self.port),
self.database,
self.user
)
}
pub fn where_stored(&self) -> String {
match &self.source {
Source::Pgpass(_) => ini::collapse_tilde(&pgpass_path().to_string_lossy()),
Source::MyCnf(name) => format!(
"{} [{}]",
ini::collapse_tilde(&engines::mysql::cnf_path().to_string_lossy()),
engines::mysql::group_of(name)
),
}
}
pub fn covers(&self, c: &Conn) -> bool {
if self.engine != c.engine {
return false;
}
match &self.source {
Source::MyCnf(name) => name == &c.name,
Source::Pgpass(_) => {
let host = if c.host.is_empty() {
"localhost"
} else {
&c.host
};
field_matches(&self.host, host)
&& field_matches(&self.port, &c.port_or_default())
&& field_matches(&self.database, &c.database)
&& field_matches(&self.user, &c.user)
}
}
}
}
fn field_matches(pattern: &str, value: &str) -> bool {
pattern == "*" || (!value.is_empty() && pattern == value)
}
pub fn pgpass_path() -> PathBuf {
if let Some(p) = std::env::var_os("PGPASSFILE") {
return PathBuf::from(p);
}
dirs::home_dir().unwrap_or_default().join(".pgpass")
}
pub fn list() -> Vec<Cred> {
let mut out = pgpass_entries();
for c in engines::mysql::list() {
if engines::mysql::has_password(&c.name) {
out.push(Cred {
engine: Engine::MySql,
host: if c.host.is_empty() {
"localhost".into()
} else {
c.host.clone()
},
port: c.port_or_default(),
database: c.database.clone(),
user: c.user.clone(),
source: Source::MyCnf(c.name),
});
}
}
out
}
fn pgpass_entries() -> Vec<Cred> {
let Ok(text) = fs::read_to_string(pgpass_path()) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
.enumerate()
.filter_map(|(i, line)| {
let f = split_pgpass(line);
(f.len() == 5).then(|| Cred {
engine: Engine::Pg,
host: f[0].clone(),
port: f[1].clone(),
database: f[2].clone(),
user: f[3].clone(),
source: Source::Pgpass(i),
})
})
.collect()
}
fn split_pgpass(line: &str) -> Vec<String> {
let mut out = vec![String::new()];
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
if let Some(next) = chars.next() {
out.last_mut().unwrap().push(next);
}
}
':' => out.push(String::new()),
_ => out.last_mut().unwrap().push(c),
}
}
out
}
fn escape_pgpass(value: &str) -> String {
value.replace('\\', "\\\\").replace(':', "\\:")
}
pub fn set_pg(host: &str, port: &str, database: &str, user: &str, password: &str) -> Result<()> {
set_pg_in(&pgpass_path(), host, port, database, user, password)
}
pub fn set_pg_in(
path: &Path,
host: &str,
port: &str,
database: &str,
user: &str,
password: &str,
) -> Result<()> {
let text = fs::read_to_string(path).unwrap_or_default();
let entry = [host, port, database, user, password]
.iter()
.map(|f| escape_pgpass(f))
.collect::<Vec<_>>()
.join(":");
let mut out: Vec<String> = Vec::new();
let mut replaced = false;
for line in text.lines() {
let f = split_pgpass(line);
let same = f.len() == 5 && f[0] == host && f[1] == port && f[2] == database && f[3] == user;
if same && !replaced {
out.push(entry.clone());
replaced = true;
} else {
out.push(line.to_string());
}
}
if !replaced {
out.push(entry);
}
if path.exists() {
ini::backup(path)?;
}
write_pgpass(path, &out)
}
pub fn delete(cred: &Cred) -> Result<()> {
match &cred.source {
Source::MyCnf(name) => engines::mysql::set_password(name, None),
Source::Pgpass(idx) => {
let path = pgpass_path();
let text =
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
ini::backup(&path)?;
let mut seen = 0usize;
let out: Vec<String> = text
.lines()
.filter(|line| {
let blank = line.trim().is_empty() || line.trim_start().starts_with('#');
if blank {
return true;
}
let keep = seen != *idx;
seen += 1;
keep
})
.map(str::to_string)
.collect();
write_pgpass(&path, &out)
}
}
}
fn write_pgpass(path: &std::path::Path, lines: &[String]) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let mut body = lines.join("\n");
if !body.is_empty() {
body.push('\n');
}
fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
ini::harden(path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct Temp(PathBuf);
impl Temp {
fn new(body: &str) -> Temp {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("easysql-pgpass-{}-{stamp}", std::process::id()));
fs::write(&path, body).unwrap();
Temp(path)
}
fn lines(&self) -> Vec<String> {
fs::read_to_string(&self.0)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
if let Some(dir) = self.0.parent() {
let stem = format!("{}.bak.", self.0.file_name().unwrap().to_string_lossy());
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
if e.file_name().to_string_lossy().starts_with(&stem) {
let _ = fs::remove_file(e.path());
}
}
}
}
}
}
#[test]
fn a_colon_or_backslash_in_a_field_is_escaped() {
let f = Temp::new("");
set_pg_in(&f.0, "db.example.com", "5432", "app", "me", r"pa:ss\word").unwrap();
assert_eq!(
f.lines(),
vec![r"db.example.com:5432:app:me:pa\:ss\\word".to_string()]
);
}
#[test]
fn an_entry_with_the_same_first_four_fields_is_replaced_in_place() {
let f = Temp::new("db.example.com:5432:app:me:old\nother.example.com:5432:app:me:keep\n");
set_pg_in(&f.0, "db.example.com", "5432", "app", "me", "new").unwrap();
assert_eq!(
f.lines(),
vec![
"db.example.com:5432:app:me:new".to_string(),
"other.example.com:5432:app:me:keep".to_string(),
]
);
}
#[test]
fn a_different_user_on_the_same_database_appends_instead() {
let f = Temp::new("db.example.com:5432:app:me:mine\n");
set_pg_in(&f.0, "db.example.com", "5432", "app", "you", "yours").unwrap();
assert_eq!(
f.lines(),
vec![
"db.example.com:5432:app:me:mine".to_string(),
"db.example.com:5432:app:you:yours".to_string(),
]
);
}
#[test]
fn the_file_is_chmod_600() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let f = Temp::new("");
set_pg_in(&f.0, "db.example.com", "5432", "app", "me", "s3cret").unwrap();
let mode = fs::metadata(&f.0).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "libpq ignores a .pgpass that is not 0600");
}
}
}