Skip to main content

alien_bindings/providers/postgres/
local.rs

1use crate::error::{ErrorData, Result};
2use crate::providers::postgres::{resolve_params, PostgresConnectionInput, ResolvedPostgres};
3use crate::traits::PostgresTlsPolicy;
4use alien_core::bindings::{ExternalPostgresSslMode, PostgresBinding};
5use alien_error::AlienError;
6
7/// Backwards-compatible name for a resolved inline-password Postgres binding.
8///
9/// All Postgres backends now use the same resolved handle internally, but local
10/// platform consumers historically constructed this public type directly.
11pub type LocalPostgres = ResolvedPostgres;
12
13impl ResolvedPostgres {
14    /// Resolves connection parameters from a binding that carries its password inline:
15    /// the Local (developer) and External (BYO / Kubernetes) variants.
16    ///
17    /// The cloud variants carry a secret *locator* instead and need an async read against
18    /// that cloud's secret store, so they have their own providers (`aurora`, `cloud_sql`,
19    /// `flexible_server`) that `BindingsProvider::load_postgres` dispatches to.
20    pub fn from_binding(binding_name: &str, binding: &PostgresBinding) -> Result<Self> {
21        let params = match binding {
22            PostgresBinding::Local(b) => resolve_params(
23                binding_name,
24                PostgresConnectionInput {
25                    host: &b.host,
26                    port: &b.port,
27                    database: &b.database,
28                    username: &b.username,
29                    // Inline password is already a concrete `String` (the type forbids an
30                    // unresolved ref).
31                    password: &b.password,
32                    tls: PostgresTlsPolicy::disabled(),
33                },
34            )?,
35            PostgresBinding::External(b) => {
36                let tls = match b.ssl_mode {
37                    ExternalPostgresSslMode::VerifyFull => {
38                        PostgresTlsPolicy::verify_full_with_system_roots()
39                    }
40                    ExternalPostgresSslMode::Disable => PostgresTlsPolicy::disabled(),
41                };
42                resolve_params(
43                    binding_name,
44                    PostgresConnectionInput {
45                        host: &b.host,
46                        port: &b.port,
47                        database: &b.database,
48                        username: &b.username,
49                        password: &b.password,
50                        tls,
51                    },
52                )?
53            }
54            // Listed explicitly rather than via a catch-all so a future `PostgresBinding`
55            // variant forces a compile error to route it somewhere. Reaching this arm means
56            // `load_postgres` dispatched a cloud binding to the wrong provider — a bug here,
57            // not bad user configuration.
58            PostgresBinding::Aurora(_)
59            | PostgresBinding::CloudSql(_)
60            | PostgresBinding::FlexibleServer(_) => {
61                return Err(AlienError::new(ErrorData::config_invalid(
62                    binding_name,
63                    "Cloud Postgres bindings carry a password secret locator and must be \
64                     resolved by their own cloud provider, not the inline-password provider",
65                )));
66            }
67        };
68        Ok(Self::new(params))
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::traits::{Postgres, SslMode};
76    use alien_core::bindings::BindingValue;
77
78    #[test]
79    fn local_binding_resolves_to_disable_sslmode_connection_string() {
80        let binding = PostgresBinding::local("127.0.0.1", 6543, "db", "alien", "p@ss/word");
81        let pg = ResolvedPostgres::from_binding("db", &binding).expect("local binding resolves");
82        let params = pg.connection_params();
83        assert_eq!(params.host, "127.0.0.1");
84        assert_eq!(params.port, 6543);
85        assert_eq!(params.sslmode(), SslMode::Disable);
86        // password is percent-encoded; sslmode=disable for Local (plain TCP).
87        assert_eq!(
88            pg.connection_string(),
89            "postgres://alien:p%40ss%2Fword@127.0.0.1:6543/db?sslmode=disable"
90        );
91    }
92
93    #[test]
94    fn external_binding_defaults_to_verify_full_sslmode() {
95        let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "p@ss/word");
96        let pg = ResolvedPostgres::from_binding("db", &binding).expect("external binding resolves");
97
98        assert_eq!(pg.connection_params().sslmode(), SslMode::VerifyFull);
99        assert_eq!(
100            pg.connection_string(),
101            "postgres://alien:p%40ss%2Fword@db.internal:5432/app?sslmode=verify-full"
102        );
103    }
104
105    #[test]
106    fn external_binding_allows_explicit_plaintext_opt_out() {
107        let binding: PostgresBinding = serde_json::from_value(serde_json::json!({
108            "service": "external",
109            "host": "db.internal",
110            "port": 5432,
111            "database": "app",
112            "username": "alien",
113            "password": "secret",
114            "sslMode": "disable",
115        }))
116        .expect("external plaintext binding deserializes");
117        let pg = ResolvedPostgres::from_binding("db", &binding).expect("external binding resolves");
118
119        assert_eq!(pg.connection_params().sslmode(), SslMode::Disable);
120        assert_eq!(
121            pg.connection_string(),
122            "postgres://alien:secret@db.internal:5432/app?sslmode=disable"
123        );
124    }
125
126    // The connection string must percent-encode the RFC 3986 sub-delims ! * ' ( ) that JS's
127    // encodeURIComponent leaves literal, so any resolver that reimplements this (in any
128    // language) produces byte-identical URLs for any generated password. This pins the
129    // encoding contract; `crates/alien-bindings/src/traits.rs::encode_userinfo` is the
130    // single implementation every backend shares.
131    #[test]
132    fn connection_string_percent_encodes_rfc3986_sub_delims() {
133        let binding = PostgresBinding::local("h", 5432, "db", "alien", "a!b*c'd(e)f");
134        let pg = ResolvedPostgres::from_binding("db", &binding).expect("local binding resolves");
135        assert_eq!(
136            pg.connection_string(),
137            "postgres://alien:a%21b%2Ac%27d%28e%29f@h:5432/db?sslmode=disable"
138        );
139    }
140
141    /// A cloud binding routed here is a dispatch bug, not user error, but it must still
142    /// fail loudly rather than resolve without a password.
143    #[test]
144    fn cloud_binding_is_rejected_by_the_inline_password_provider() {
145        let binding = PostgresBinding::Aurora(alien_core::bindings::AuroraPostgresBinding {
146            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
147            port: BindingValue::value(5432),
148            database: "db".into(),
149            username: "alien".into(),
150            password_secret_arn: "arn:aws:secretsmanager:...".into(),
151        });
152
153        let error = ResolvedPostgres::from_binding("db", &binding)
154            .expect_err("cloud bindings must not resolve without their secret");
155
156        assert_eq!(error.code, "BINDING_CONFIG_INVALID");
157    }
158}