use std::net::IpAddr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Trust,
Reject,
Scram,
Password,
}
impl Method {
fn parse(w: &str) -> Option<Self> {
match w.to_ascii_lowercase().as_str() {
"trust" => Some(Self::Trust),
"reject" => Some(Self::Reject),
"scram-sha-256" => Some(Self::Scram),
"password" => Some(Self::Password),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConnType {
Local,
Host,
HostSsl,
HostNoSsl,
}
#[derive(Debug, Clone)]
struct Rule {
conn: ConnType,
database: String,
user: String,
net: Option<(IpAddr, u8)>,
method: Method,
}
#[derive(Debug, Clone, Default)]
pub struct Hba {
rules: Vec<Rule>,
}
fn parse_net(tok: &str) -> Option<(IpAddr, u8)> {
if tok.eq_ignore_ascii_case("all") {
return Some((IpAddr::from([0u8, 0, 0, 0]), 0));
}
let (addr, bits) = tok.split_once('/')?;
let ip: IpAddr = addr.parse().ok()?;
let len: u8 = bits.parse().ok()?;
let max = if ip.is_ipv4() { 32 } else { 128 };
(len <= max).then_some((ip, len))
}
fn in_net(peer: IpAddr, (net, bits): (IpAddr, u8)) -> bool {
if bits == 0 && net == IpAddr::from([0u8, 0, 0, 0]) {
return true;
}
match (peer, net) {
(IpAddr::V4(p), IpAddr::V4(n)) => {
let (p, n) = (u32::from(p), u32::from(n));
bits == 0 || (p ^ n) >> (32 - u32::from(bits)) == 0
}
(IpAddr::V6(p), IpAddr::V6(n)) => {
let (p, n) = (u128::from(p), u128::from(n));
bits == 0 || (p ^ n) >> (128 - u32::from(bits)) == 0
}
(IpAddr::V6(p), IpAddr::V4(_)) => p
.to_ipv4_mapped()
.is_some_and(|v4| in_net(IpAddr::V4(v4), (net, bits))),
(IpAddr::V4(_), IpAddr::V6(n)) => n
.to_ipv4_mapped()
.is_some_and(|v4| in_net(peer, (IpAddr::V4(v4), bits.saturating_sub(96)))),
}
}
fn matches_name(pattern: &str, name: &str) -> bool {
pattern == "all" || pattern.eq_ignore_ascii_case(name)
}
impl Hba {
pub fn parse(text: &str) -> Result<Self, String> {
let mut rules = Vec::new();
for (n, raw) in text.lines().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
let lineno = n + 1;
let conn = match f.first().map(|s| s.to_ascii_lowercase()) {
Some(ref s) if s == "local" => ConnType::Local,
Some(ref s) if s == "host" => ConnType::Host,
Some(ref s) if s == "hostssl" => ConnType::HostSsl,
Some(ref s) if s == "hostnossl" => ConnType::HostNoSsl,
other => {
return Err(format!(
"line {lineno}: expected local/host/hostssl/hostnossl, found {}",
other.unwrap_or_default()
));
}
};
let local = conn == ConnType::Local;
let want = if local { 4 } else { 5 };
if f.len() < want {
return Err(format!(
"line {lineno}: expected {want} fields, found {}",
f.len()
));
}
let net =
if local {
None
} else {
Some(parse_net(f[3]).ok_or_else(|| {
format!("line {lineno}: {:?} is not an address/prefix", f[3])
})?)
};
let method_tok = f[if local { 3 } else { 4 }];
let method = Method::parse(method_tok).ok_or_else(|| {
format!(
"line {lineno}: authentication method {method_tok:?} is not one SPG \
performs (trust, reject, scram-sha-256, password)"
)
})?;
rules.push(Rule {
conn,
database: f[1].to_string(),
user: f[2].to_string(),
net,
method,
});
}
Ok(Self { rules })
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[must_use]
pub fn method_for(
&self,
peer: IpAddr,
database: &str,
user: &str,
tls: bool,
) -> Option<Method> {
self.rules
.iter()
.find(|r| {
let conn_ok = match r.conn {
ConnType::Local => false,
ConnType::Host => true,
ConnType::HostSsl => tls,
ConnType::HostNoSsl => !tls,
};
conn_ok
&& matches_name(&r.database, database)
&& matches_name(&r.user, user)
&& r.net.is_some_and(|n| in_net(peer, n))
})
.map(|r| r.method)
}
}
#[cfg(test)]
mod tests {
use super::{Hba, Method};
use std::net::IpAddr;
fn ip(s: &str) -> IpAddr {
s.parse().expect("addr")
}
#[test]
fn the_shipped_postgres_file_parses_and_matches() {
let hba = Hba::parse(
"# comment\n\
\n\
local all all trust\n\
host all all 127.0.0.1/32 trust\n\
host all all ::1/128 trust\n\
host all all all scram-sha-256\n",
)
.expect("parses");
assert!(!hba.is_empty());
assert_eq!(
hba.method_for(ip("127.0.0.1"), "spg", "admin", false),
Some(Method::Trust)
);
assert_eq!(
hba.method_for(ip("::1"), "spg", "admin", false),
Some(Method::Trust)
);
assert_eq!(
hba.method_for(ip("10.1.2.3"), "spg", "admin", false),
Some(Method::Scram)
);
assert_eq!(
Hba::parse("local all all trust")
.expect("parses")
.method_for(ip("127.0.0.1"), "spg", "admin", false),
None
);
}
#[test]
fn the_first_matching_line_decides() {
let hba = Hba::parse(
"host all all 127.0.0.1/32 reject\n\
host all all all trust\n",
)
.expect("parses");
assert_eq!(
hba.method_for(ip("127.0.0.1"), "spg", "admin", false),
Some(Method::Reject)
);
assert_eq!(
hba.method_for(ip("10.0.0.1"), "spg", "admin", false),
Some(Method::Trust)
);
assert_eq!(
Hba::parse("host all all 10.0.0.0/8 trust")
.expect("parses")
.method_for(ip("127.0.0.1"), "spg", "admin", false),
None
);
}
#[test]
fn the_ssl_variants_split_on_tls() {
let hba = Hba::parse(
"hostssl all all all scram-sha-256\n\
hostnossl all all all reject\n",
)
.expect("parses");
assert_eq!(
hba.method_for(ip("10.0.0.1"), "spg", "u", true),
Some(Method::Scram)
);
assert_eq!(
hba.method_for(ip("10.0.0.1"), "spg", "u", false),
Some(Method::Reject)
);
}
#[test]
fn a_named_database_or_user_narrows_the_line() {
let hba = Hba::parse(
"host app alice 0.0.0.0/0 trust\n\
host all all 0.0.0.0/0 reject\n",
)
.expect("parses");
assert_eq!(
hba.method_for(ip("10.0.0.1"), "app", "alice", false),
Some(Method::Trust)
);
assert_eq!(
hba.method_for(ip("10.0.0.1"), "other", "alice", false),
Some(Method::Reject)
);
assert_eq!(
hba.method_for(ip("10.0.0.1"), "app", "bob", false),
Some(Method::Reject)
);
}
#[test]
fn an_ipv4_mapped_address_matches_the_ipv4_rule() {
let hba = Hba::parse("host all all 127.0.0.1/32 reject").expect("parses");
assert_eq!(
hba.method_for(ip("::ffff:127.0.0.1"), "spg", "u", false),
Some(Method::Reject)
);
assert_eq!(
hba.method_for(ip("::ffff:10.0.0.1"), "spg", "u", false),
None
);
}
#[test]
fn a_malformed_line_names_itself() {
for (text, want) in [
("garbage\n", "line 1"),
("host all all 127.0.0.1/32 kerberos\n", "kerberos"),
("host all all not-an-address trust\n", "not-an-address"),
("host all all\n", "expected 5 fields"),
] {
let err = Hba::parse(text).expect_err(text);
assert!(err.contains(want), "{text:?}: {err}");
}
assert!(
Hba::parse("# just a comment\n\n")
.expect("parses")
.is_empty()
);
}
}