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 DEK_KEY: &str = "__dek";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum EntryKind {
11 Plain,
12 Secret,
13}
14
15pub fn set_plain(project_dir: &Path, key: &str, value: &str) -> Result<()> {
16 reject_reserved(key)?;
17 let env_path = env_yaml_path(project_dir);
18 let mut mapping = load_mapping(&env_path)?;
19 mapping.insert(
20 serde_yaml::Value::String(key.to_string()),
21 serde_yaml::Value::String(value.to_string()),
22 );
23 save_mapping(&env_path, &mapping)?;
24 Ok(())
25}
26
27pub async fn set_secret(
28 project_dir: &Path,
29 key: &str,
30 value: &str,
31 creds: &Credentials,
32) -> Result<()> {
33 reject_reserved(key)?;
34 let env_path = env_yaml_path(project_dir);
35 let mut mapping = load_mapping(&env_path)?;
36
37 let dek_ct = ensure_dek(&mut mapping, creds).await?;
38 let ciphertext = call_secrets_encrypt(creds, &dek_ct, value).await?;
39
40 let mut entry = serde_yaml::Mapping::new();
41 entry.insert(
42 serde_yaml::Value::String("secret".to_string()),
43 serde_yaml::Value::String(ciphertext),
44 );
45 mapping.insert(
46 serde_yaml::Value::String(key.to_string()),
47 serde_yaml::Value::Mapping(entry),
48 );
49
50 save_mapping(&env_path, &mapping)?;
51 Ok(())
52}
53
54pub fn list_entries(project_dir: &Path) -> Result<Vec<(String, EntryKind)>> {
55 let env_path = env_yaml_path(project_dir);
56 if !env_path.exists() {
57 return Ok(Vec::new());
58 }
59 let mapping = load_mapping(&env_path)?;
60 let mut out = Vec::new();
61 for (key_v, value_v) in &mapping {
62 let Some(name) = key_v.as_str() else {
63 continue;
64 };
65 if name == DEK_KEY {
66 continue;
67 }
68 let kind = match value_v {
69 serde_yaml::Value::Mapping(m) if m.contains_key("secret") => EntryKind::Secret,
70 _ => EntryKind::Plain,
71 };
72 out.push((name.to_string(), kind));
73 }
74 out.sort_by(|a, b| a.0.cmp(&b.0));
75 Ok(out)
76}
77
78pub fn unset(project_dir: &Path, key: &str) -> Result<()> {
79 reject_reserved(key)?;
80 let env_path = env_yaml_path(project_dir);
81 if !env_path.exists() {
82 return Err(anyhow!("no {} in this project", env_path.display()));
83 }
84 let mut mapping = load_mapping(&env_path)?;
85 if mapping
86 .remove(serde_yaml::Value::String(key.to_string()))
87 .is_none()
88 {
89 return Err(anyhow!("{} not present in {}", key, env_path.display()));
90 }
91 save_mapping(&env_path, &mapping)?;
92 Ok(())
93}
94
95pub fn env_yaml_path(project_dir: &Path) -> PathBuf {
96 project_dir.join(ENV_YAML_FILENAME)
97}
98
99fn reject_reserved(key: &str) -> Result<()> {
100 if key == DEK_KEY {
101 return Err(anyhow!("{} is reserved", DEK_KEY));
102 }
103 Ok(())
104}
105
106async fn ensure_dek(mapping: &mut serde_yaml::Mapping, creds: &Credentials) -> Result<String> {
107 if let Some(serde_yaml::Value::Mapping(dek_map)) = mapping.get(DEK_KEY)
108 && let Some(serde_yaml::Value::String(s)) = dek_map.get("encrypted")
109 {
110 return Ok(s.clone());
111 }
112
113 let ct = call_secrets_init(creds).await?;
114 let mut dek_entry = serde_yaml::Mapping::new();
115 dek_entry.insert(
116 serde_yaml::Value::String("encrypted".to_string()),
117 serde_yaml::Value::String(ct.clone()),
118 );
119 mapping.insert(
120 serde_yaml::Value::String(DEK_KEY.to_string()),
121 serde_yaml::Value::Mapping(dek_entry),
122 );
123 Ok(ct)
124}
125
126async fn call_secrets_init(creds: &Credentials) -> Result<String> {
127 #[derive(Serialize)]
128 struct Empty {}
129 #[derive(Deserialize)]
130 #[serde(tag = "t", rename_all_fields = "camelCase")]
131 enum InitResp {
132 Ok { encrypted_dek: String },
133 Unauthorized,
134 Error { message: String },
135 }
136 let resp: InitResp = post_action(creds, "secrets_init", &Empty {}).await?;
137 match resp {
138 InitResp::Ok { encrypted_dek } => Ok(encrypted_dek),
139 InitResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
140 InitResp::Error { message } => Err(anyhow!("control error: {message}")),
141 }
142}
143
144async fn call_secrets_encrypt(
145 creds: &Credentials,
146 encrypted_dek: &str,
147 value: &str,
148) -> Result<String> {
149 #[derive(Serialize)]
150 #[serde(rename_all = "camelCase")]
151 struct Req<'a> {
152 encrypted_dek: &'a str,
153 value: &'a str,
154 }
155 #[derive(Deserialize)]
156 #[serde(tag = "t", rename_all_fields = "camelCase")]
157 enum EncResp {
158 Ok { ciphertext: String },
159 Unauthorized,
160 Error { message: String },
161 }
162 let resp: EncResp = post_action(
163 creds,
164 "secrets_encrypt",
165 &Req {
166 encrypted_dek,
167 value,
168 },
169 )
170 .await?;
171 match resp {
172 EncResp::Ok { ciphertext } => Ok(ciphertext),
173 EncResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
174 EncResp::Error { message } => Err(anyhow!("control error: {message}")),
175 }
176}
177
178async fn post_action<I, O>(creds: &Credentials, name: &str, body: &I) -> Result<O>
179where
180 I: Serialize,
181 O: serde::de::DeserializeOwned,
182{
183 let url = format!(
184 "{}/__forte_action/{}",
185 creds.control_url.trim_end_matches('/'),
186 name
187 );
188 let resp = reqwest::Client::new()
189 .post(url)
190 .bearer_auth(&creds.token)
191 .json(body)
192 .send()
193 .await?;
194 if !resp.status().is_success() {
195 let status = resp.status();
196 let body = resp.text().await.unwrap_or_default();
197 return Err(anyhow!("control returned {status}: {body}"));
198 }
199 let parsed = resp.json().await?;
200 Ok(parsed)
201}
202
203fn load_mapping(p: &Path) -> Result<serde_yaml::Mapping> {
204 if !p.exists() {
205 return Ok(serde_yaml::Mapping::new());
206 }
207 let content = std::fs::read_to_string(p)?;
208 if content.trim().is_empty() {
209 return Ok(serde_yaml::Mapping::new());
210 }
211 let value: serde_yaml::Value = serde_yaml::from_str(&content)?;
212 match value {
213 serde_yaml::Value::Mapping(m) => Ok(m),
214 _ => Err(anyhow!("{} must contain a mapping", p.display())),
215 }
216}
217
218fn save_mapping(p: &Path, m: &serde_yaml::Mapping) -> Result<()> {
219 let s = serde_yaml::to_string(&serde_yaml::Value::Mapping(m.clone()))?;
220 std::fs::write(p, s)?;
221 Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use tempfile::TempDir;
228
229 #[test]
230 fn set_plain_writes_scalar() {
231 let dir = TempDir::new().unwrap();
232 set_plain(dir.path(), "FOO", "bar").unwrap();
233 let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
234 assert!(content.contains("FOO: bar"));
235 }
236
237 #[test]
238 fn set_plain_overwrites_existing() {
239 let dir = TempDir::new().unwrap();
240 set_plain(dir.path(), "FOO", "first").unwrap();
241 set_plain(dir.path(), "FOO", "second").unwrap();
242 let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
243 assert!(content.contains("FOO: second"));
244 assert!(!content.contains("FOO: first"));
245 }
246
247 #[test]
248 fn list_entries_classifies_plain_and_secret() {
249 let dir = TempDir::new().unwrap();
250 std::fs::write(
251 env_yaml_path(dir.path()),
252 "__dek:\n encrypted: ct\nFOO: plain_value\nBAR:\n secret: ct\n",
253 )
254 .unwrap();
255 let entries = list_entries(dir.path()).unwrap();
256 assert_eq!(
257 entries,
258 vec![
259 ("BAR".to_string(), EntryKind::Secret),
260 ("FOO".to_string(), EntryKind::Plain),
261 ]
262 );
263 }
264
265 #[test]
266 fn unset_removes_entry() {
267 let dir = TempDir::new().unwrap();
268 set_plain(dir.path(), "FOO", "bar").unwrap();
269 set_plain(dir.path(), "BAZ", "qux").unwrap();
270 unset(dir.path(), "FOO").unwrap();
271 let entries = list_entries(dir.path()).unwrap();
272 assert_eq!(entries, vec![("BAZ".to_string(), EntryKind::Plain)]);
273 }
274
275 #[test]
276 fn unset_missing_key_errors() {
277 let dir = TempDir::new().unwrap();
278 set_plain(dir.path(), "FOO", "bar").unwrap();
279 let err = unset(dir.path(), "NOPE").unwrap_err();
280 assert!(err.to_string().contains("not present"));
281 }
282
283 #[test]
284 fn reject_reserved_dek_key() {
285 let dir = TempDir::new().unwrap();
286 let err = set_plain(dir.path(), DEK_KEY, "x").unwrap_err();
287 assert!(err.to_string().contains("reserved"));
288 }
289}