1use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20use serde::Deserialize;
21
22use crate::errors::{LeashError, Result};
23
24pub const ENV_CACHE_TTL: Duration = Duration::from_secs(60);
26
27#[derive(Debug, Clone)]
29pub struct Env {
30 inner: Arc<EnvInner>,
31}
32
33#[derive(Debug)]
34struct EnvInner {
35 platform_url: String,
36 api_key: Option<String>,
37 http: reqwest::Client,
38 cache: Mutex<HashMap<String, Cached>>,
39}
40
41#[derive(Debug, Clone)]
42struct Cached {
43 value: Option<String>,
44 expires_at: Instant,
45}
46
47impl Env {
48 pub(crate) fn new(platform_url: String, api_key: Option<String>, http: reqwest::Client) -> Self {
49 Self {
50 inner: Arc::new(EnvInner {
51 platform_url: platform_url.trim_end_matches('/').to_string(),
52 api_key,
53 http,
54 cache: Mutex::new(HashMap::new()),
55 }),
56 }
57 }
58
59 pub async fn get(&self, key: &str) -> Result<Option<String>> {
65 self.resolve(key, false).await
66 }
67
68 pub async fn get_fresh(&self, key: &str) -> Result<Option<String>> {
73 self.resolve(key, true).await
74 }
75
76 pub async fn get_many(&self, keys: &[&str]) -> Result<HashMap<String, Option<String>>> {
83 let mut out = HashMap::with_capacity(keys.len());
84 for key in keys {
85 let value = self.get(key).await?;
86 out.insert((*key).to_string(), value);
87 }
88 Ok(out)
89 }
90
91 async fn resolve(&self, key: &str, fresh: bool) -> Result<Option<String>> {
92 let now = Instant::now();
93 if !fresh {
94 if let Some(cached) = self.cache_get(key, now) {
95 return Ok(cached);
96 }
97 }
98
99 let value = self.fetch(key).await?;
100 self.cache_put(key, value.clone(), now + ENV_CACHE_TTL);
101 Ok(value)
102 }
103
104 fn cache_get(&self, key: &str, now: Instant) -> Option<Option<String>> {
105 let cache = self.inner.cache.lock().ok()?;
106 let entry = cache.get(key)?;
107 if entry.expires_at > now {
108 Some(entry.value.clone())
109 } else {
110 None
111 }
112 }
113
114 fn cache_put(&self, key: &str, value: Option<String>, expires_at: Instant) {
115 if let Ok(mut cache) = self.inner.cache.lock() {
116 cache.insert(
117 key.to_string(),
118 Cached {
119 value,
120 expires_at,
121 },
122 );
123 }
124 }
125
126 async fn fetch(&self, key: &str) -> Result<Option<String>> {
127 let api_key = self.inner.api_key.as_deref().ok_or_else(|| {
128 LeashError::Unauthorized {
129 message: "LEASH_API_KEY is required to call Env::get.".to_string(),
130 }
131 })?;
132
133 let url = format!(
134 "{}/api/apps/me/secrets/{}",
135 self.inner.platform_url,
136 percent_encode(key)
137 );
138
139 let resp = self
140 .inner
141 .http
142 .get(&url)
143 .bearer_auth(api_key)
144 .send()
145 .await?;
146
147 let status = resp.status();
148 let raw = resp.bytes().await?;
149
150 match status.as_u16() {
151 400 => Err(LeashError::UpstreamError {
152 status: 400,
153 message: format!(
154 "Invalid env-var key: '{key}'. Names must match /^[A-Za-z_][A-Za-z0-9_]*$/ and be \u{2264}100 chars."
155 ),
156 }),
157 401 => Err(LeashError::Unauthorized {
158 message: "Missing or invalid LEASH_API_KEY.".to_string(),
159 }),
160 402 => {
161 let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
162 let required_plan = parsed
163 .as_ref()
164 .and_then(|v| v.get("requiredPlan"))
165 .and_then(|v| v.as_str())
166 .map(|s| s.to_string());
167 let suffix = required_plan
168 .as_deref()
169 .map(|p| format!(" (requiredPlan: {p})"))
170 .unwrap_or_default();
171 Err(LeashError::UpgradeRequired {
172 message: format!("Env::get requires the Growth plan or above{suffix}."),
173 })
174 }
175 404 => Ok(None),
178 502 => {
179 let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
180 let msg = parsed
181 .as_ref()
182 .and_then(|v| v.get("error"))
183 .and_then(|v| v.as_str())
184 .map(|s| s.to_string())
185 .unwrap_or_else(|| "Secret source resync failed on the platform side.".into());
186 Err(LeashError::UpstreamError {
187 status: 502,
188 message: msg,
189 })
190 }
191 s if s >= 400 => Err(LeashError::UpstreamError {
192 status: s,
193 message: format!("Unexpected response from platform: HTTP {s}"),
194 }),
195 _ => {
196 let body: SecretBody =
197 serde_json::from_slice(&raw).map_err(|_| LeashError::MalformedResponse {
198 message: format!("Platform returned unexpected shape for key '{key}'."),
199 })?;
200 Ok(Some(body.value))
201 }
202 }
203 }
204}
205
206#[derive(Debug, Deserialize)]
207struct SecretBody {
208 value: String,
209}
210
211fn percent_encode(input: &str) -> String {
214 let mut out = String::with_capacity(input.len());
215 for byte in input.bytes() {
216 match byte {
217 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
218 out.push(byte as char);
219 }
220 _ => {
221 out.push('%');
222 out.push_str(&format!("{byte:02X}"));
223 }
224 }
225 }
226 out
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn percent_encodes_reserved_chars() {
235 assert_eq!(percent_encode("OPENAI_API_KEY"), "OPENAI_API_KEY");
236 assert_eq!(percent_encode("a/b"), "a%2Fb");
237 assert_eq!(percent_encode("a b"), "a%20b");
238 }
239}