Skip to main content

alien_bindings/providers/postgres/
local.rs

1use crate::error::{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                    binding_name: binding_name.to_string(),
59                    reason: format!(
60                        "{backend} Postgres bindings are resolved in-process by the workload SDK, \
61                         not this Rust provider"
62                    ),
63                }));
64            }
65        };
66        Ok(Self::new(params))
67    }
68}
69
70#[allow(clippy::too_many_arguments)]
71fn resolve_params(
72    binding_name: &str,
73    host: &BindingValue<String>,
74    port: &BindingValue<u16>,
75    database: &BindingValue<String>,
76    username: &BindingValue<String>,
77    password: &str,
78    sslmode: SslMode,
79) -> Result<PostgresConnectionParams> {
80    let invalid = |field: &str| ErrorData::BindingConfigInvalid {
81        binding_name: binding_name.to_string(),
82        reason: format!("Failed to extract '{}' from Postgres binding", field),
83    };
84    Ok(PostgresConnectionParams {
85        host: host
86            .clone()
87            .into_value(binding_name, "host")
88            .context(invalid("host"))?,
89        port: port
90            .clone()
91            .into_value(binding_name, "port")
92            .context(invalid("port"))?,
93        database: database
94            .clone()
95            .into_value(binding_name, "database")
96            .context(invalid("database"))?,
97        username: username
98            .clone()
99            .into_value(binding_name, "username")
100            .context(invalid("username"))?,
101        // Inline password is already a concrete `String` (the type forbids an unresolved ref).
102        password: password.to_string(),
103        sslmode,
104    })
105}
106
107impl Binding for LocalPostgres {}
108
109impl Postgres for LocalPostgres {
110    fn connection_params(&self) -> PostgresConnectionParams {
111        self.params.clone()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn local_binding_resolves_to_disable_sslmode_connection_string() {
121        let binding = PostgresBinding::local("127.0.0.1", 6543, "db", "alien", "p@ss/word");
122        let pg = LocalPostgres::from_binding("db", &binding).expect("local binding resolves");
123        let params = pg.connection_params();
124        assert_eq!(params.host, "127.0.0.1");
125        assert_eq!(params.port, 6543);
126        // password is percent-encoded; sslmode=disable for Local (plain TCP).
127        assert_eq!(
128            pg.connection_string(),
129            "postgres://alien:p%40ss%2Fword@127.0.0.1:6543/db?sslmode=disable"
130        );
131    }
132
133    // The connection string must percent-encode the RFC 3986 sub-delims ! * ' ( ) that JS's
134    // encodeURIComponent leaves literal, so the Rust resolver and the TS SDK resolver
135    // (packages/sdk/.../postgres.ts `encodeUserinfo`) produce byte-identical URLs for any
136    // generated password. This pins the shared encoding contract on the Rust side.
137    #[test]
138    fn connection_string_percent_encodes_rfc3986_sub_delims() {
139        let binding = PostgresBinding::local("h", 5432, "db", "alien", "a!b*c'd(e)f");
140        let pg = LocalPostgres::from_binding("db", &binding).expect("local binding resolves");
141        assert_eq!(
142            pg.connection_string(),
143            "postgres://alien:a%21b%2Ac%27d%28e%29f@h:5432/db?sslmode=disable"
144        );
145    }
146
147    #[test]
148    fn cloud_binding_resolution_is_rejected_in_this_build() {
149        let binding = PostgresBinding::Aurora(alien_core::bindings::AuroraPostgresBinding {
150            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
151            port: BindingValue::value(5432),
152            database: "db".into(),
153            username: "alien".into(),
154            password_secret_arn: "arn:aws:secretsmanager:...".into(),
155        });
156        assert!(LocalPostgres::from_binding("db", &binding).is_err());
157    }
158}