Skip to main content

sofie_core/
local_database.rs

1use std::net::IpAddr;
2
3use crate::encoding::percent;
4
5/// What drops or fills a database unasked runs only on one on this machine. Read as
6/// tokio-postgres reads the url, so the guard and the driver agree on where it connects.
7#[must_use]
8pub fn is_local_database(url: &str) -> bool {
9    if url.starts_with("sqlite://") || url == "sqlite::memory:" {
10        return true;
11    }
12    let Some(rest) = url
13        .strip_prefix("postgres://")
14        .or_else(|| url.strip_prefix("postgresql://"))
15    else {
16        return false;
17    };
18    // The driver ends the credentials at the first `@`, even one in the path or a parameter.
19    let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
20    let end = rest.find(['/', '?']).unwrap_or(rest.len());
21    let (hosts, path) = rest.split_at(end);
22    let parameters = path.split_once('?').map_or("", |(_, query)| query);
23    let Some(parameter_hosts) = parameter_hosts(parameters) else {
24        return false;
25    };
26    hosts
27        .split(',')
28        .map(without_port)
29        .chain(parameter_hosts.iter().map(String::as_str))
30        .all(is_local_host)
31}
32
33/// `None` when a parameter points elsewhere. The driver decodes keys and values, so a name
34/// written as `h%6Fst` still counts; a host list is split even where unix reads one socket path.
35fn parameter_hosts(parameters: &str) -> Option<Vec<String>> {
36    let mut hosts = Vec::new();
37    for parameter in parameters.split('&') {
38        let Some((key, value)) = parameter.split_once('=') else {
39            continue;
40        };
41        match (decoded(key)?.as_str(), decoded(value)?) {
42            ("host", value) => hosts.extend(value.split(',').map(str::to_owned)),
43            ("hostaddr", addresses) if !addresses.split(',').all(is_loopback) => return None,
44            // Names a connection defined elsewhere, which may be anywhere.
45            ("service", _) => return None,
46            _ => {}
47        }
48    }
49    Some(hosts)
50}
51
52fn decoded(text: &str) -> Option<String> {
53    String::from_utf8(percent::decode(text.as_bytes())?).ok()
54}
55
56fn is_loopback(address: &str) -> bool {
57    address
58        .parse::<IpAddr>()
59        .is_ok_and(|address| address.is_loopback())
60}
61
62fn without_port(host: &str) -> &str {
63    if let Some(bracketed) = host.strip_prefix('[') {
64        return bracketed.split(']').next().unwrap_or_default();
65    }
66    host.split(':').next().unwrap_or_default()
67}
68
69/// No host means the default socket; a path, written or percent-encoded, is a socket too.
70fn is_local_host(host: &str) -> bool {
71    host.is_empty()
72        || host.starts_with('/')
73        || host.to_ascii_lowercase().starts_with("%2f")
74        || matches!(host, "localhost" | "127.0.0.1" | "::1")
75}
76
77#[cfg(test)]
78mod tests {
79    use super::is_local_database;
80
81    #[test]
82    fn a_database_on_this_machine_is_local() {
83        for url in [
84            "postgres://sofie:sofie@127.0.0.1:25432/sofie",
85            "postgres://sofie:p%40ss@localhost/shop",
86            "postgres://localhost/shop?hostaddr=127.0.0.1",
87            "postgresql://[::1]:5432/shop",
88            "postgres:///shop",
89            "postgres://sofie@/shop?host=/var/run/postgresql",
90            "postgres://sofie@%2Fvar%2Frun%2Fpostgresql/shop",
91            "sqlite://app.db",
92            "sqlite::memory:",
93        ] {
94            assert!(is_local_database(url), "{url}");
95        }
96    }
97
98    #[test]
99    fn any_other_host_is_not() {
100        for url in [
101            "postgres://sofie:sofie@db.example.com/shop",
102            "postgres://sofie:sofie@10.0.0.5:5432/shop",
103            "postgres://localhost,db.example.com/shop",
104            "postgres://localhost/shop?host=db.example.com",
105            "postgres://sofie:sofie@localhost.example.com/shop",
106            "host=localhost dbname=shop",
107            "sqlite:app.db",
108            "postgres://localhost/shop?hostaddr=10.0.0.5",
109            "postgres://localhost/shop?hostaddr=127.0.0.1,10.0.0.5",
110            "postgres://localhost/shop?application_name=@db.example.com",
111            "postgres://sofie:p@ss@localhost/shop",
112            "postgres://localhost/shop?service=production",
113            "postgres://localhost/shop?host=/tmp,db.example.com",
114            "postgres://localhost/shop?host=%2Ftmp%2Cdb.example.com",
115            "postgres://localhost/shop?h%6Fstaddr=10.0.0.5",
116            "postgres://localhost/shop?%68ost=db.example.com",
117        ] {
118            assert!(!is_local_database(url), "{url}");
119        }
120    }
121}