Skip to main content

ferro_wallet/
config.rs

1//! Permissive env-driven configuration for ferro-wallet.
2//!
3//! Per D-02: missing Apple cluster ⇒ `apple: None`, missing Google cluster ⇒ `google: None`.
4//! `WalletConfig::from_env` never returns `Err` for absent wallet env vars — callers
5//! gate features on `apple.is_some()` / `google.is_some()`. `APP_NAME` / `APP_URL`
6//! fall back to the same defaults as `framework::config::AppConfig`
7//! (`"Ferro Application"` / `"http://localhost:8080"`).
8//!
9//! `Debug` is derived on every config struct for ergonomics; it includes raw PEM
10//! strings for [`AppleConfig`] / [`GoogleConfig`]. Callers MUST NOT log these
11//! configs in production — the exposure parity is identical to
12//! `ferro_stripe::config::StripeConfig`'s `api_key` exposure.
13
14use crate::WalletError;
15
16/// Top-level wallet configuration.
17///
18/// `apple` and `google` are independently optional — a deployment can ship
19/// Apple-only, Google-only, or neither without errors.
20#[derive(Debug, Clone)]
21pub struct WalletConfig {
22    /// Application name. Defaults to `"Ferro Application"` when `APP_NAME` is unset.
23    pub app_name: String,
24    /// Application URL. Defaults to `"http://localhost:8080"` when `APP_URL` is unset.
25    pub app_url: String,
26    /// Apple Wallet signing cluster, or `None` if any required Apple env var is missing.
27    pub apple: Option<AppleConfig>,
28    /// Google Wallet signing cluster, or `None` if any required Google env var is missing.
29    pub google: Option<GoogleConfig>,
30}
31
32/// Apple `.pkpass` signing material (PEM-encoded).
33///
34/// All five required fields must be present in the environment for the cluster
35/// to populate — any missing required var ⇒ `WalletConfig.apple = None`.
36#[derive(Debug, Clone)]
37pub struct AppleConfig {
38    /// `APPLE_WALLET_PASS_TYPE_ID` — Apple-issued pass type identifier.
39    pub pass_type_id: String,
40    /// `APPLE_WALLET_TEAM_ID` — Apple Developer team identifier.
41    pub team_id: String,
42    /// `APPLE_WALLET_CERT_PEM` — pass-type signing certificate (PEM).
43    pub cert_pem: String,
44    /// `APPLE_WALLET_KEY_PEM` — pass-type signing private key (PEM).
45    pub key_pem: String,
46    /// `APPLE_WALLET_KEY_PASSWORD` — optional passphrase for the encrypted key.
47    pub key_password: Option<String>,
48    /// `APPLE_WALLET_WWDR_PEM` — Apple WWDR intermediate certificate (PEM).
49    pub wwdr_pem: String,
50}
51
52/// Google Wallet service-account credentials.
53///
54/// All three required fields must be present in the environment for the cluster
55/// to populate — any missing required var ⇒ `WalletConfig.google = None`.
56#[derive(Debug, Clone)]
57pub struct GoogleConfig {
58    /// `GOOGLE_WALLET_ISSUER_ID` — Google Wallet issuer ID (numeric string).
59    pub issuer_id: String,
60    /// `GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL` — service-account email.
61    pub service_account_email: String,
62    /// `GOOGLE_WALLET_SERVICE_ACCOUNT_KEY_PEM` — RSA private key (PEM, PKCS#8 or PKCS#1).
63    pub service_account_private_key_pem: String,
64}
65
66impl WalletConfig {
67    /// Loads wallet configuration from environment variables.
68    ///
69    /// `APP_NAME` and `APP_URL` fall back to the same defaults as
70    /// `framework::config::AppConfig::from_env`: `"Ferro Application"` and
71    /// `"http://localhost:8080"`.
72    ///
73    /// Per D-02 (permissive semantics): missing Apple or Google env vars NEVER
74    /// produce an error. Callers gate downstream functionality on
75    /// `wallet_cfg.apple.is_some()` / `.google.is_some()`.
76    ///
77    /// # Errors
78    ///
79    /// Returns `Result` for forward compatibility — additional non-wallet
80    /// validation may be added in future versions. The current implementation
81    /// never returns `Err`.
82    pub fn from_env() -> Result<Self, WalletError> {
83        // Defaults mirror framework::config::providers::app.rs lines 20 + 23.
84        let app_name =
85            std::env::var("APP_NAME").unwrap_or_else(|_| "Ferro Application".to_string());
86        let app_url =
87            std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
88
89        let apple = AppleConfig::from_env_optional()?;
90        let google = GoogleConfig::from_env_optional()?;
91
92        Ok(Self {
93            app_name,
94            app_url,
95            apple,
96            google,
97        })
98    }
99}
100
101impl AppleConfig {
102    /// Returns `Ok(Some(cfg))` only when all five required Apple env vars are set;
103    /// `Ok(None)` if ANY of them is missing. Never returns `Err`.
104    ///
105    /// Required vars: `APPLE_WALLET_PASS_TYPE_ID`, `APPLE_WALLET_TEAM_ID`,
106    /// `APPLE_WALLET_CERT_PEM`, `APPLE_WALLET_KEY_PEM`, `APPLE_WALLET_WWDR_PEM`.
107    /// Optional: `APPLE_WALLET_KEY_PASSWORD`.
108    pub fn from_env_optional() -> Result<Option<Self>, WalletError> {
109        let pass_type_id = match non_empty_env("APPLE_WALLET_PASS_TYPE_ID") {
110            Some(v) => v,
111            None => return Ok(None),
112        };
113        let team_id = match non_empty_env("APPLE_WALLET_TEAM_ID") {
114            Some(v) => v,
115            None => return Ok(None),
116        };
117        let cert_pem = match non_empty_env("APPLE_WALLET_CERT_PEM") {
118            Some(v) => v,
119            None => return Ok(None),
120        };
121        let key_pem = match non_empty_env("APPLE_WALLET_KEY_PEM") {
122            Some(v) => v,
123            None => return Ok(None),
124        };
125        let wwdr_pem = match non_empty_env("APPLE_WALLET_WWDR_PEM") {
126            Some(v) => v,
127            None => return Ok(None),
128        };
129        let key_password = non_empty_env("APPLE_WALLET_KEY_PASSWORD");
130
131        Ok(Some(Self {
132            pass_type_id,
133            team_id,
134            cert_pem,
135            key_pem,
136            key_password,
137            wwdr_pem,
138        }))
139    }
140}
141
142impl GoogleConfig {
143    /// Returns `Ok(Some(cfg))` only when all three required Google env vars are set
144    /// AND non-empty; `Ok(None)` if ANY of them is missing or set to "". Never returns `Err`.
145    ///
146    /// Required vars: `GOOGLE_WALLET_ISSUER_ID`,
147    /// `GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL`, `GOOGLE_WALLET_SERVICE_ACCOUNT_KEY_PEM`.
148    pub fn from_env_optional() -> Result<Option<Self>, WalletError> {
149        let issuer_id = match non_empty_env("GOOGLE_WALLET_ISSUER_ID") {
150            Some(v) => v,
151            None => return Ok(None),
152        };
153        let service_account_email = match non_empty_env("GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL") {
154            Some(v) => v,
155            None => return Ok(None),
156        };
157        let service_account_private_key_pem =
158            match non_empty_env("GOOGLE_WALLET_SERVICE_ACCOUNT_KEY_PEM") {
159                Some(v) => v,
160                None => return Ok(None),
161            };
162
163        Ok(Some(Self {
164            issuer_id,
165            service_account_email,
166            service_account_private_key_pem,
167        }))
168    }
169}
170
171/// Read `name` from the environment, returning `None` for both missing AND empty
172/// values. Empty-as-missing makes the wallet env block in `.env.example`
173/// scaffolds safe to ship without forcing the builder to fail at startup when
174/// an operator copies the example to `.env` without filling values.
175fn non_empty_env(name: &str) -> Option<String> {
176    match std::env::var(name) {
177        Ok(v) if !v.is_empty() => Some(v),
178        _ => None,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::sync::Mutex;
186
187    /// All env-touching tests in this module serialize through this mutex to avoid
188    /// process-global env-var races under `cargo test`'s default parallel execution.
189    /// Without this guard, e.g. `from_env_apple_all_set_returns_some` could set
190    /// `APPLE_WALLET_PASS_TYPE_ID` while `from_env_apple_missing_is_none` is mid-assert,
191    /// producing flaky behaviour. ferro-stripe sidesteps this by having only one
192    /// env-touching test in its module; ferro-wallet has seven, so an in-process
193    /// mutex is the minimal correctness fix.
194    static ENV_LOCK: Mutex<()> = Mutex::new(());
195
196    /// Names of every wallet env var the tests in this module read or mutate.
197    const APP_VARS: &[&str] = &["APP_NAME", "APP_URL"];
198    const APPLE_VARS: &[&str] = &[
199        "APPLE_WALLET_PASS_TYPE_ID",
200        "APPLE_WALLET_TEAM_ID",
201        "APPLE_WALLET_CERT_PEM",
202        "APPLE_WALLET_KEY_PEM",
203        "APPLE_WALLET_WWDR_PEM",
204        "APPLE_WALLET_KEY_PASSWORD",
205    ];
206    const GOOGLE_VARS: &[&str] = &[
207        "GOOGLE_WALLET_ISSUER_ID",
208        "GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL",
209        "GOOGLE_WALLET_SERVICE_ACCOUNT_KEY_PEM",
210    ];
211
212    /// RAII guard that snapshots a set of env vars on construction and restores
213    /// them on drop, so even an assertion panic leaves the process env clean for
214    /// the next test. Implements the save→remove→assert→restore pattern
215    /// (RESEARCH.md Pitfall 6) in panic-safe form.
216    struct EnvGuard {
217        saved: Vec<(&'static str, Option<String>)>,
218    }
219
220    impl EnvGuard {
221        fn capture(vars: &[&'static str]) -> Self {
222            let saved = vars
223                .iter()
224                .map(|v| (*v, std::env::var(v).ok()))
225                .collect::<Vec<_>>();
226            for v in vars {
227                std::env::remove_var(v);
228            }
229            Self { saved }
230        }
231
232        /// Capture every wallet-relevant env var into a single guard. Order of
233        /// concatenation is irrelevant — restoration happens uniformly on drop.
234        fn capture_all() -> Self {
235            let mut all = Vec::with_capacity(APP_VARS.len() + APPLE_VARS.len() + GOOGLE_VARS.len());
236            all.extend(APP_VARS);
237            all.extend(APPLE_VARS);
238            all.extend(GOOGLE_VARS);
239            Self::capture(&all)
240        }
241    }
242
243    impl Drop for EnvGuard {
244        fn drop(&mut self) {
245            for (name, value) in &self.saved {
246                match value {
247                    Some(v) => std::env::set_var(name, v),
248                    None => std::env::remove_var(name),
249                }
250            }
251        }
252    }
253
254    /// Acquire the env-serialization lock, recovering from a poisoned mutex
255    /// (a previous test panicked) so subsequent tests still run deterministically.
256    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
257        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
258    }
259
260    /// ACC-1a: Missing Apple cluster ⇒ `apple: None`. Never errors.
261    #[test]
262    fn from_env_apple_missing_is_none() {
263        let _lock = lock_env();
264        let _env = EnvGuard::capture_all();
265
266        let cfg =
267            WalletConfig::from_env().expect("from_env must not error when wallet vars are absent");
268
269        assert!(
270            cfg.apple.is_none(),
271            "expected apple cluster to be None when APPLE_WALLET_* vars are absent"
272        );
273    }
274
275    /// ACC-1b: Missing Google cluster ⇒ `google: None`. Never errors.
276    #[test]
277    fn from_env_google_missing_is_none() {
278        let _lock = lock_env();
279        let _env = EnvGuard::capture_all();
280
281        let cfg =
282            WalletConfig::from_env().expect("from_env must not error when wallet vars are absent");
283
284        assert!(
285            cfg.google.is_none(),
286            "expected google cluster to be None when GOOGLE_WALLET_* vars are absent"
287        );
288    }
289
290    /// ACC-1c: APP_NAME / APP_URL defaults must match
291    /// `framework::config::providers::app.rs` lines 20 + 23.
292    #[test]
293    fn from_env_defaults_match_appconfig() {
294        let _lock = lock_env();
295        let _env = EnvGuard::capture_all();
296
297        let cfg =
298            WalletConfig::from_env().expect("from_env must not error when wallet vars are absent");
299
300        assert_eq!(
301            cfg.app_name, "Ferro Application",
302            "APP_NAME default must match framework::config::AppConfig::from_env"
303        );
304        assert_eq!(
305            cfg.app_url, "http://localhost:8080",
306            "APP_URL default must match framework::config::AppConfig::from_env"
307        );
308    }
309
310    /// Setting four of five required Apple vars must still resolve to `apple: None`.
311    /// Guards against accidental partial-cluster acceptance.
312    #[test]
313    fn from_env_apple_partial_returns_none() {
314        let _lock = lock_env();
315        let _env = EnvGuard::capture_all();
316
317        std::env::set_var("APPLE_WALLET_PASS_TYPE_ID", "pass.com.example.test");
318        std::env::set_var("APPLE_WALLET_TEAM_ID", "TEAMID1234");
319        std::env::set_var(
320            "APPLE_WALLET_CERT_PEM",
321            "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n",
322        );
323        std::env::set_var(
324            "APPLE_WALLET_KEY_PEM",
325            "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n",
326        );
327        // APPLE_WALLET_WWDR_PEM intentionally unset.
328
329        let cfg =
330            WalletConfig::from_env().expect("from_env must not error on partial Apple cluster");
331
332        assert!(
333            cfg.apple.is_none(),
334            "expected apple cluster to be None when WWDR_PEM is unset"
335        );
336    }
337
338    /// All five required Apple vars set + optional password ⇒ populated cluster
339    /// with matching field values.
340    #[test]
341    fn from_env_apple_all_set_returns_some() {
342        let _lock = lock_env();
343        let _env = EnvGuard::capture_all();
344
345        std::env::set_var("APPLE_WALLET_PASS_TYPE_ID", "pass.com.example.test");
346        std::env::set_var("APPLE_WALLET_TEAM_ID", "TEAMID1234");
347        std::env::set_var("APPLE_WALLET_CERT_PEM", "cert-pem-bytes");
348        std::env::set_var("APPLE_WALLET_KEY_PEM", "key-pem-bytes");
349        std::env::set_var("APPLE_WALLET_WWDR_PEM", "wwdr-pem-bytes");
350        std::env::set_var("APPLE_WALLET_KEY_PASSWORD", "secret");
351
352        let cfg =
353            WalletConfig::from_env().expect("from_env must not error when wallet vars are present");
354        let apple = cfg
355            .apple
356            .expect("apple cluster must populate when all required vars set");
357
358        assert_eq!(apple.pass_type_id, "pass.com.example.test");
359        assert_eq!(apple.team_id, "TEAMID1234");
360        assert_eq!(apple.cert_pem, "cert-pem-bytes");
361        assert_eq!(apple.key_pem, "key-pem-bytes");
362        assert_eq!(apple.wwdr_pem, "wwdr-pem-bytes");
363        assert_eq!(apple.key_password.as_deref(), Some("secret"));
364    }
365
366    /// All three required Google vars set ⇒ populated cluster with matching field values.
367    #[test]
368    fn from_env_google_all_set_returns_some() {
369        let _lock = lock_env();
370        let _env = EnvGuard::capture_all();
371
372        std::env::set_var("GOOGLE_WALLET_ISSUER_ID", "3388000000000000000");
373        std::env::set_var(
374            "GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL",
375            "sa@example.iam.gserviceaccount.com",
376        );
377        std::env::set_var(
378            "GOOGLE_WALLET_SERVICE_ACCOUNT_KEY_PEM",
379            "private-key-pem-bytes",
380        );
381
382        let cfg =
383            WalletConfig::from_env().expect("from_env must not error when wallet vars are present");
384        let google = cfg
385            .google
386            .expect("google cluster must populate when all required vars set");
387
388        assert_eq!(google.issuer_id, "3388000000000000000");
389        assert_eq!(
390            google.service_account_email,
391            "sa@example.iam.gserviceaccount.com"
392        );
393        assert_eq!(
394            google.service_account_private_key_pem,
395            "private-key-pem-bytes"
396        );
397    }
398
399    /// D-02 invariant: regardless of which wallet env vars are present or absent,
400    /// `from_env` MUST return `Ok`. This is the load-bearing permissive guarantee.
401    #[test]
402    fn from_env_never_errors_on_missing_wallet_vars() {
403        let _lock = lock_env();
404        let _env = EnvGuard::capture_all();
405
406        assert!(
407            WalletConfig::from_env().is_ok(),
408            "from_env must never error when all wallet env vars are absent"
409        );
410    }
411}