1//! The sidecar's one config file (TOML).
2//!
3//! ```toml
4//! [target]
5//! server = "target"          # the foreign server's local name
6//! host = "db.example.com"
7//! port = 5432
8//! dbname = "app"
9//! sslmode = "verify-full"    # the default
10//! use_remote_estimate = true # the default
11//! schemas = ["public"]       # imported into same-named local schemas
12//!
13//! [[mapping]]
14//! local_user = "app"
15//! remote_user = "app_ro"
16//! password_file = "/run/secrets/app_ro"  # or $POSTJEVSQL_TARGET_PASSWORD
17//!
18//! [jev]
19//! api_key_file = "/run/secrets/typesafe" # or $TYPESAFE_API_KEY
20//! ```
21
22use serde::Deserialize;
23use std::path::PathBuf;
24
25use crate::secret::Sources;
26
27#[derive(Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct Config {
30    pub target: Target,
31    #[serde(default, rename = "mapping")]
32    pub mappings: Vec<Mapping>,
33    #[serde(default)]
34    pub jev: Jev,
35}
36
37#[derive(Debug, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Target {
40    #[serde(default = "default_server")]
41    pub server: String,
42    pub host: String,
43    #[serde(default = "default_port")]
44    pub port: u16,
45    pub dbname: String,
46    #[serde(default)]
47    pub sslmode: SslMode,
48    pub sslrootcert: Option<String>,
49    #[serde(default = "yes")]
50    pub use_remote_estimate: bool,
51    #[serde(default = "default_fetch_size")]
52    pub fetch_size: u32,
53    pub schemas: Vec<String>,
54}
55
56/// libpq's `sslmode`. `verify-full` is the default and what the contract
57/// requires of a real target; the weaker modes exist for loopback tests.
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum SslMode {
61    Disable,
62    Require,
63    VerifyCa,
64    #[default]
65    VerifyFull,
66}
67
68impl SslMode {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            SslMode::Disable => "disable",
72            SslMode::Require => "require",
73            SslMode::VerifyCa => "verify-ca",
74            SslMode::VerifyFull => "verify-full",
75        }
76    }
77}
78
79#[derive(Debug, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Mapping {
82    pub local_user: String,
83    pub remote_user: String,
84    pub password_file: Option<PathBuf>,
85    /// The env var that may hold the password instead of the file.
86    #[serde(default = "default_password_env")]
87    pub password_env: String,
88}
89
90impl Mapping {
91    pub fn password(&self) -> Sources {
92        Sources {
93            what: format!("the target password for {}", self.remote_user),
94            file: self.password_file.clone(),
95            env: self.password_env.clone(),
96        }
97    }
98}
99
100#[derive(Debug, Default, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct Jev {
103    pub api_key_file: Option<PathBuf>,
104}
105
106impl Jev {
107    /// The API key the sidecar's server reads: `jev.api_key_file` or
108    /// `TYPESAFE_API_KEY`, exactly one (CLAUDE.md *Cost and safety*).
109    pub fn api_key(&self) -> Sources {
110        Sources {
111            what: "the Jev API key".into(),
112            file: self.api_key_file.clone(),
113            env: "TYPESAFE_API_KEY".into(),
114        }
115    }
116}
117
118impl Config {
119    pub fn parse(text: &str) -> Result<Config, String> {
120        let config: Config = toml::from_str(text).map_err(|e| e.to_string())?;
121        if config.target.schemas.is_empty() {
122            return Err("target.schemas names no schema to import".into());
123        }
124        Ok(config)
125    }
126}
127
128fn default_server() -> String {
129    "target".into()
130}
131fn default_port() -> u16 {
132    5432
133}
134fn yes() -> bool {
135    true
136}
137fn default_fetch_size() -> u32 {
138    100
139}
140fn default_password_env() -> String {
141    "POSTJEVSQL_TARGET_PASSWORD".into()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn defaults() {
150        let c = Config::parse(
151            "[target]\nhost = \"h\"\ndbname = \"d\"\nschemas = [\"public\"]\n\
152             [[mapping]]\nlocal_user = \"a\"\nremote_user = \"b\"\n",
153        )
154        .unwrap();
155        assert_eq!(c.target.sslmode, SslMode::VerifyFull);
156        assert!(c.target.use_remote_estimate);
157        assert_eq!(c.target.port, 5432);
158        assert_eq!(c.mappings[0].password_env, "POSTJEVSQL_TARGET_PASSWORD");
159        assert_eq!(c.jev.api_key().env, "TYPESAFE_API_KEY");
160    }
161
162    #[test]
163    fn refuses_unknown_keys_and_no_schemas() {
164        assert!(Config::parse("[target]\nhost=\"h\"\ndbname=\"d\"\nschemas=[]\n").is_err());
165        assert!(
166            Config::parse("[target]\nhost=\"h\"\ndbname=\"d\"\nschemas=[\"p\"]\nextensions=\"x\"\n")
167                .is_err()
168        );
169    }
170}