1use crate::credentials::Credentials;
2use anyhow::{Result, anyhow};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const ENV_YAML_FILENAME: &str = "env.yaml";
7const ENV_LOCAL_YAML_FILENAME: &str = "env.local.yaml";
8const DEK_KEY: &str = "__dek";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EntryKind {
12 Plain,
13 Secret,
14}
15
16#[derive(Debug, Default, PartialEq, Eq)]
19pub struct DevEnv {
20 pub vars: Vec<(String, String)>,
21 pub unresolved_secrets: Vec<String>,
24}
25
26pub fn set_plain(project_dir: &Path, key: &str, value: &str) -> Result<()> {
27 set_plain_at(&env_yaml_path(project_dir), key, value)
28}
29
30pub fn set_plain_local(project_dir: &Path, key: &str, value: &str) -> Result<()> {
31 set_plain_at(&env_local_yaml_path(project_dir), key, value)
32}
33
34fn set_plain_at(env_path: &Path, key: &str, value: &str) -> Result<()> {
35 reject_reserved(key)?;
36 let mut mapping = load_mapping(env_path)?;
37 mapping.insert(
38 serde_yaml::Value::String(key.to_string()),
39 serde_yaml::Value::String(value.to_string()),
40 );
41 save_mapping(env_path, &mapping)?;
42 Ok(())
43}
44
45pub fn load_dev_env(project_dir: &Path) -> Result<DevEnv> {
46 let mut dev_env = DevEnv::default();
47
48 let shared_path = env_yaml_path(project_dir);
49 for (key, value) in load_mapping(&shared_path)? {
50 let key = entry_key(&shared_path, &key)?;
51 if key == DEK_KEY {
52 continue;
53 }
54 match value {
55 serde_yaml::Value::Mapping(m) if m.contains_key("secret") => {
56 dev_env.unresolved_secrets.push(key.to_string());
57 }
58 value => dev_env
59 .vars
60 .push((key.to_string(), plain_value(&shared_path, key, &value)?)),
61 }
62 }
63
64 let local_path = env_local_yaml_path(project_dir);
65 for (key, value) in load_mapping(&local_path)? {
66 let key = entry_key(&local_path, &key)?;
67 reject_reserved(key)?;
68 if matches!(&value, serde_yaml::Value::Mapping(m) if m.contains_key("secret")) {
69 return Err(anyhow!(
70 "{}: {key} uses `secret:`, but {ENV_LOCAL_YAML_FILENAME} is plaintext and gitignored — write the value directly",
71 local_path.display()
72 ));
73 }
74 let value = plain_value(&local_path, key, &value)?;
75 dev_env.unresolved_secrets.retain(|k| k != key);
76 match dev_env.vars.iter_mut().find(|(k, _)| k == key) {
77 Some(existing) => existing.1 = value,
78 None => dev_env.vars.push((key.to_string(), value)),
79 }
80 }
81
82 Ok(dev_env)
83}
84
85fn entry_key<'a>(path: &Path, key: &'a serde_yaml::Value) -> Result<&'a str> {
86 key.as_str()
87 .ok_or_else(|| anyhow!("{}: key is not a string", path.display()))
88}
89
90fn plain_value(path: &Path, key: &str, value: &serde_yaml::Value) -> Result<String> {
94 match value {
95 serde_yaml::Value::String(s) => Ok(s.clone()),
96 _ => Err(anyhow!(
97 "{}: {key} must be a quoted string or a `secret:` mapping",
98 path.display()
99 )),
100 }
101}
102
103pub async fn set_secret(
104 project_dir: &Path,
105 key: &str,
106 value: &str,
107 creds: &Credentials,
108) -> Result<()> {
109 reject_reserved(key)?;
110 let env_path = env_yaml_path(project_dir);
111 let mut mapping = load_mapping(&env_path)?;
112
113 let dek_ct = ensure_dek(&mut mapping, creds).await?;
114 let ciphertext = call_secrets_encrypt(creds, &dek_ct, value).await?;
115
116 let mut entry = serde_yaml::Mapping::new();
117 entry.insert(
118 serde_yaml::Value::String("secret".to_string()),
119 serde_yaml::Value::String(ciphertext),
120 );
121 mapping.insert(
122 serde_yaml::Value::String(key.to_string()),
123 serde_yaml::Value::Mapping(entry),
124 );
125
126 save_mapping(&env_path, &mapping)?;
127 Ok(())
128}
129
130pub fn list_entries(project_dir: &Path) -> Result<Vec<(String, EntryKind)>> {
131 let env_path = env_yaml_path(project_dir);
132 if !env_path.exists() {
133 return Ok(Vec::new());
134 }
135 let mapping = load_mapping(&env_path)?;
136 let mut out = Vec::new();
137 for (key_v, value_v) in &mapping {
138 let Some(name) = key_v.as_str() else {
139 continue;
140 };
141 if name == DEK_KEY {
142 continue;
143 }
144 let kind = match value_v {
145 serde_yaml::Value::Mapping(m) if m.contains_key("secret") => EntryKind::Secret,
146 _ => EntryKind::Plain,
147 };
148 out.push((name.to_string(), kind));
149 }
150 out.sort_by(|a, b| a.0.cmp(&b.0));
151 Ok(out)
152}
153
154pub fn unset(project_dir: &Path, key: &str) -> Result<()> {
155 reject_reserved(key)?;
156 let env_path = env_yaml_path(project_dir);
157 if !env_path.exists() {
158 return Err(anyhow!("no {} in this project", env_path.display()));
159 }
160 let mut mapping = load_mapping(&env_path)?;
161 if mapping
162 .remove(serde_yaml::Value::String(key.to_string()))
163 .is_none()
164 {
165 return Err(anyhow!("{} not present in {}", key, env_path.display()));
166 }
167 save_mapping(&env_path, &mapping)?;
168 Ok(())
169}
170
171pub fn env_yaml_path(project_dir: &Path) -> PathBuf {
172 project_dir.join(ENV_YAML_FILENAME)
173}
174
175pub fn env_local_yaml_path(project_dir: &Path) -> PathBuf {
176 project_dir.join(ENV_LOCAL_YAML_FILENAME)
177}
178
179fn reject_reserved(key: &str) -> Result<()> {
180 if key == DEK_KEY {
181 return Err(anyhow!("{} is reserved", DEK_KEY));
182 }
183 Ok(())
184}
185
186async fn ensure_dek(mapping: &mut serde_yaml::Mapping, creds: &Credentials) -> Result<String> {
187 if let Some(serde_yaml::Value::Mapping(dek_map)) = mapping.get(DEK_KEY)
188 && let Some(serde_yaml::Value::String(s)) = dek_map.get("encrypted")
189 {
190 return Ok(s.clone());
191 }
192
193 let ct = call_secrets_init(creds).await?;
194 let mut dek_entry = serde_yaml::Mapping::new();
195 dek_entry.insert(
196 serde_yaml::Value::String("encrypted".to_string()),
197 serde_yaml::Value::String(ct.clone()),
198 );
199 mapping.insert(
200 serde_yaml::Value::String(DEK_KEY.to_string()),
201 serde_yaml::Value::Mapping(dek_entry),
202 );
203 Ok(ct)
204}
205
206async fn call_secrets_init(creds: &Credentials) -> Result<String> {
207 #[derive(Serialize)]
208 struct Empty {}
209 #[derive(Deserialize)]
210 #[serde(tag = "t", rename_all_fields = "camelCase")]
211 enum InitResp {
212 Ok { encrypted_dek: String },
213 Unauthorized,
214 Error { message: String },
215 }
216 let resp: InitResp = post_action(creds, "secrets_init", &Empty {}).await?;
217 match resp {
218 InitResp::Ok { encrypted_dek } => Ok(encrypted_dek),
219 InitResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
220 InitResp::Error { message } => Err(anyhow!("control error: {message}")),
221 }
222}
223
224async fn call_secrets_encrypt(
225 creds: &Credentials,
226 encrypted_dek: &str,
227 value: &str,
228) -> Result<String> {
229 #[derive(Serialize)]
230 #[serde(rename_all = "camelCase")]
231 struct Req<'a> {
232 encrypted_dek: &'a str,
233 value: &'a str,
234 }
235 #[derive(Deserialize)]
236 #[serde(tag = "t", rename_all_fields = "camelCase")]
237 enum EncResp {
238 Ok { ciphertext: String },
239 Unauthorized,
240 Error { message: String },
241 }
242 let resp: EncResp = post_action(
243 creds,
244 "secrets_encrypt",
245 &Req {
246 encrypted_dek,
247 value,
248 },
249 )
250 .await?;
251 match resp {
252 EncResp::Ok { ciphertext } => Ok(ciphertext),
253 EncResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
254 EncResp::Error { message } => Err(anyhow!("control error: {message}")),
255 }
256}
257
258async fn post_action<I, O>(creds: &Credentials, name: &str, body: &I) -> Result<O>
259where
260 I: Serialize,
261 O: serde::de::DeserializeOwned,
262{
263 let url = format!(
264 "{}/__forte_action/{}",
265 creds.control_url.trim_end_matches('/'),
266 name
267 );
268 let resp = reqwest::Client::new()
269 .post(url)
270 .bearer_auth(&creds.token)
271 .json(body)
272 .send()
273 .await?;
274 if !resp.status().is_success() {
275 let status = resp.status();
276 let body = resp.text().await.unwrap_or_default();
277 return Err(anyhow!("control returned {status}: {body}"));
278 }
279 let parsed = resp.json().await?;
280 Ok(parsed)
281}
282
283fn load_mapping(p: &Path) -> Result<serde_yaml::Mapping> {
284 if !p.exists() {
285 return Ok(serde_yaml::Mapping::new());
286 }
287 let content = std::fs::read_to_string(p)?;
288 if content.trim().is_empty() {
289 return Ok(serde_yaml::Mapping::new());
290 }
291 let value: serde_yaml::Value = serde_yaml::from_str(&content)?;
292 match value {
293 serde_yaml::Value::Mapping(m) => Ok(m),
294 _ => Err(anyhow!("{} must contain a mapping", p.display())),
295 }
296}
297
298fn save_mapping(p: &Path, m: &serde_yaml::Mapping) -> Result<()> {
299 let s = serde_yaml::to_string(&serde_yaml::Value::Mapping(m.clone()))?;
300 std::fs::write(p, s)?;
301 Ok(())
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use tempfile::TempDir;
308
309 #[test]
310 fn set_plain_writes_scalar() {
311 let dir = TempDir::new().unwrap();
312 set_plain(dir.path(), "FOO", "bar").unwrap();
313 let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
314 assert!(content.contains("FOO: bar"));
315 }
316
317 #[test]
318 fn set_plain_overwrites_existing() {
319 let dir = TempDir::new().unwrap();
320 set_plain(dir.path(), "FOO", "first").unwrap();
321 set_plain(dir.path(), "FOO", "second").unwrap();
322 let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
323 assert!(content.contains("FOO: second"));
324 assert!(!content.contains("FOO: first"));
325 }
326
327 #[test]
328 fn list_entries_classifies_plain_and_secret() {
329 let dir = TempDir::new().unwrap();
330 std::fs::write(
331 env_yaml_path(dir.path()),
332 "__dek:\n encrypted: ct\nFOO: plain_value\nBAR:\n secret: ct\n",
333 )
334 .unwrap();
335 let entries = list_entries(dir.path()).unwrap();
336 assert_eq!(
337 entries,
338 vec![
339 ("BAR".to_string(), EntryKind::Secret),
340 ("FOO".to_string(), EntryKind::Plain),
341 ]
342 );
343 }
344
345 #[test]
346 fn unset_removes_entry() {
347 let dir = TempDir::new().unwrap();
348 set_plain(dir.path(), "FOO", "bar").unwrap();
349 set_plain(dir.path(), "BAZ", "qux").unwrap();
350 unset(dir.path(), "FOO").unwrap();
351 let entries = list_entries(dir.path()).unwrap();
352 assert_eq!(entries, vec![("BAZ".to_string(), EntryKind::Plain)]);
353 }
354
355 #[test]
356 fn unset_missing_key_errors() {
357 let dir = TempDir::new().unwrap();
358 set_plain(dir.path(), "FOO", "bar").unwrap();
359 let err = unset(dir.path(), "NOPE").unwrap_err();
360 assert!(err.to_string().contains("not present"));
361 }
362
363 #[test]
364 fn reject_reserved_dek_key() {
365 let dir = TempDir::new().unwrap();
366 let err = set_plain(dir.path(), DEK_KEY, "x").unwrap_err();
367 assert!(err.to_string().contains("reserved"));
368 }
369
370 #[test]
371 fn dev_env_is_empty_without_files() {
372 let dir = TempDir::new().unwrap();
373 assert_eq!(load_dev_env(dir.path()).unwrap(), DevEnv::default());
374 }
375
376 #[test]
377 fn dev_env_takes_plain_shared_entries_and_defers_secrets() {
378 let dir = TempDir::new().unwrap();
379 std::fs::write(
380 env_yaml_path(dir.path()),
381 "__dek:\n encrypted: ct\nSHARED: from_shared\nAPI_KEY:\n secret: ct\n",
382 )
383 .unwrap();
384
385 let dev_env = load_dev_env(dir.path()).unwrap();
386 assert_eq!(
387 dev_env.vars,
388 vec![("SHARED".to_string(), "from_shared".to_string())]
389 );
390 assert_eq!(dev_env.unresolved_secrets, vec!["API_KEY".to_string()]);
391 }
392
393 #[test]
394 fn local_overrides_shared_and_resolves_secret() {
395 let dir = TempDir::new().unwrap();
396 std::fs::write(
397 env_yaml_path(dir.path()),
398 "SHARED: from_shared\nAPI_KEY:\n secret: ct\n",
399 )
400 .unwrap();
401 std::fs::write(
402 env_local_yaml_path(dir.path()),
403 "SHARED: from_local\nAPI_KEY: dev_key\nLOCAL_ONLY: x\n",
404 )
405 .unwrap();
406
407 let dev_env = load_dev_env(dir.path()).unwrap();
408 assert_eq!(
409 dev_env.vars,
410 vec![
411 ("SHARED".to_string(), "from_local".to_string()),
412 ("API_KEY".to_string(), "dev_key".to_string()),
413 ("LOCAL_ONLY".to_string(), "x".to_string()),
414 ]
415 );
416 assert!(dev_env.unresolved_secrets.is_empty());
417 }
418
419 #[test]
420 fn local_secret_entry_is_rejected() {
421 let dir = TempDir::new().unwrap();
422 std::fs::write(env_local_yaml_path(dir.path()), "API_KEY:\n secret: ct\n").unwrap();
423 let err = load_dev_env(dir.path()).unwrap_err();
424 assert!(err.to_string().contains("plaintext and gitignored"));
425 }
426
427 #[test]
428 fn non_string_plain_value_is_rejected() {
429 let dir = TempDir::new().unwrap();
430 std::fs::write(env_yaml_path(dir.path()), "PORT: 8080\n").unwrap();
431 let err = load_dev_env(dir.path()).unwrap_err();
432 assert!(err.to_string().contains("must be a quoted string"));
433 }
434}