Skip to main content

alien_bindings/providers/postgres/
local.rs

1use crate::error::{binding_env_var, ErrorData, Result};
2use crate::traits::{Binding, Postgres, PostgresConnectionParams, SslMode};
3use alien_core::bindings::{BindingValue, PostgresBinding};
4use alien_error::{AlienError, Context};
5
6/// A resolved Postgres binding. Holds connection details only — it never opens or
7/// owns a server process.
8#[derive(Debug)]
9pub struct LocalPostgres {
10    params: PostgresConnectionParams,
11}
12
13impl LocalPostgres {
14    pub fn new(params: PostgresConnectionParams) -> Self {
15        Self { params }
16    }
17
18    /// Resolves connection parameters from a binding. Handles the Local and External (BYO)
19    /// variants. Cloud variants (Aurora / Cloud SQL / Flexible Server) carry only a *reference* to
20    /// the connection password in a cloud secret store; the workload SDK
21    /// (`packages/sdk/src/bindings/postgres.ts`) resolves it in-process with the workload's own
22    /// identity. This Rust provider intentionally does not read cloud secrets, so it rejects cloud
23    /// bindings by design rather than half-resolving them.
24    pub fn from_binding(binding_name: &str, binding: &PostgresBinding) -> Result<Self> {
25        let params = match binding {
26            PostgresBinding::Local(b) => resolve_params(
27                binding_name,
28                &b.host,
29                &b.port,
30                &b.database,
31                &b.username,
32                &b.password,
33                SslMode::Disable,
34            )?,
35            PostgresBinding::External(b) => resolve_params(
36                binding_name,
37                &b.host,
38                &b.port,
39                &b.database,
40                &b.username,
41                &b.password,
42                SslMode::Prefer,
43            )?,
44            // Cloud variants are resolved by the workload SDK (see the method doc), not here. Listed
45            // explicitly rather than via a catch-all so a future `PostgresBinding` variant forces a
46            // compile error to handle it. Name the backend so a reader of a later cloud plan can tell
47            // which variant was rejected.
48            PostgresBinding::Aurora(_)
49            | PostgresBinding::CloudSql(_)
50            | PostgresBinding::FlexibleServer(_) => {
51                let backend = match binding {
52                    PostgresBinding::Aurora(_) => "Aurora (AWS)",
53                    PostgresBinding::CloudSql(_) => "Cloud SQL (GCP)",
54                    PostgresBinding::FlexibleServer(_) => "Azure Flexible Server",
55                    _ => "cloud",
56                };
57                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
58                    env_var: binding_env_var(binding_name),
59                    binding_name: binding_name.to_string(),
60                    reason: format!(
61                        "{backend} Postgres bindings are resolved in-process by the workload SDK, \
62                         not this Rust provider"
63                    ),
64                }));
65            }
66        };
67        Ok(Self::new(params))
68    }
69}
70
71#[allow(clippy::too_many_arguments)]
72fn resolve_params(
73    binding_name: &str,
74    host: &BindingValue<String>,
75    port: &BindingValue<u16>,
76    database: &BindingValue<String>,
77    username: &BindingValue<String>,
78    password: &str,
79    sslmode: SslMode,
80) -> Result<PostgresConnectionParams> {
81    let invalid = |field: &str| ErrorData::BindingConfigInvalid {
82        env_var: binding_env_var(binding_name),
83        binding_name: binding_name.to_string(),
84        reason: format!("Failed to extract '{}' from Postgres binding", field),
85    };
86    Ok(PostgresConnectionParams {
87        host: host
88            .clone()
89            .into_value(binding_name, "host")
90            .context(invalid("host"))?,
91        port: port
92            .clone()
93            .into_value(binding_name, "port")
94            .context(invalid("port"))?,
95        database: database
96            .clone()
97            .into_value(binding_name, "database")
98            .context(invalid("database"))?,
99        username: username
100            .clone()
101            .into_value(binding_name, "username")
102            .context(invalid("username"))?,
103        // Inline password is already a concrete `String` (the type forbids an unresolved ref).
104        password: password.to_string(),
105        sslmode,
106    })
107}
108
109impl Binding for LocalPostgres {}
110
111impl Postgres for LocalPostgres {
112    fn connection_params(&self) -> PostgresConnectionParams {
113        self.params.clone()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn local_binding_resolves_to_disable_sslmode_connection_string() {
123        let binding = PostgresBinding::local("127.0.0.1", 6543, "db", "alien", "p@ss/word");
124        let pg = LocalPostgres::from_binding("db", &binding).expect("local binding resolves");
125        let params = pg.connection_params();
126        assert_eq!(params.host, "127.0.0.1");
127        assert_eq!(params.port, 6543);
128        // password is percent-encoded; sslmode=disable for Local (plain TCP).
129        assert_eq!(
130            pg.connection_string(),
131            "postgres://alien:p%40ss%2Fword@127.0.0.1:6543/db?sslmode=disable"
132        );
133    }
134
135    // The connection string must percent-encode the RFC 3986 sub-delims ! * ' ( ) that JS's
136    // encodeURIComponent leaves literal, so the Rust resolver and the TS SDK resolver
137    // (packages/sdk/.../postgres.ts `encodeUserinfo`) produce byte-identical URLs for any
138    // generated password. This pins the shared encoding contract on the Rust side.
139    #[test]
140    fn connection_string_percent_encodes_rfc3986_sub_delims() {
141        let binding = PostgresBinding::local("h", 5432, "db", "alien", "a!b*c'd(e)f");
142        let pg = LocalPostgres::from_binding("db", &binding).expect("local binding resolves");
143        assert_eq!(
144            pg.connection_string(),
145            "postgres://alien:a%21b%2Ac%27d%28e%29f@h:5432/db?sslmode=disable"
146        );
147    }
148
149    #[test]
150    fn cloud_binding_resolution_is_rejected_in_this_build() {
151        let binding = PostgresBinding::Aurora(alien_core::bindings::AuroraPostgresBinding {
152            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
153            port: BindingValue::value(5432),
154            database: "db".into(),
155            username: "alien".into(),
156            password_secret_arn: "arn:aws:secretsmanager:...".into(),
157        });
158        assert!(LocalPostgres::from_binding("db", &binding).is_err());
159    }
160}