Skip to main content

fn0_deploy/
env.rs

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}