Skip to main content

featherbit/admin/
env_vars.rs

1//! Admin API endpoint serving environment variable names.
2//! Consumed by the UI for dynamic configuration and secrets management.
3
4use std::sync::Arc;
5
6use axum::response::IntoResponse;
7use axum::routing::get;
8use axum::{Json, Router};
9
10use crate::state::SharedState;
11
12/// Builds the router for `/api/env-vars`.
13pub fn router() -> Router<Arc<SharedState>> {
14    Router::new().route("/api/env-vars", get(list_env_vars))
15}
16
17/// `GET /api/env-vars` — the names of all environment variables available
18/// to the current process. Returns them sorted, with no values exposed.
19///
20/// Uses `vars_os` + `to_string_lossy` rather than `std::env::vars()`, which
21/// panics on a non-Unicode name or value — an environment the gateway
22/// process didn't choose (inherited from its container/host) must not be
23/// able to take down this handler.
24async fn list_env_vars() -> impl IntoResponse {
25    let mut names: Vec<String> = std::env::vars_os()
26        .map(|(k, _)| k.to_string_lossy().into_owned())
27        .collect();
28    names.sort();
29    Json(serde_json::json!({ "names": names }))
30}
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35    use axum::body::Body;
36    use axum::http::{Request, StatusCode};
37    use tower::ServiceExt;
38
39    #[tokio::test]
40    async fn test_list_env_vars_shape_and_secret_safety() {
41        // Set a test env var with a secret value
42        std::env::set_var("FB_TEST_SECRET_VALUE_CANARY", "s3cr3t");
43
44        // Stateless handler; a state-free router instance suffices.
45        let app: Router = Router::new().route("/api/env-vars", get(list_env_vars));
46        let resp = app
47            .oneshot(Request::get("/api/env-vars").body(Body::empty()).unwrap())
48            .await
49            .unwrap();
50
51        assert_eq!(resp.status(), StatusCode::OK);
52
53        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
54            .await
55            .unwrap();
56        let body_str = String::from_utf8(bytes.to_vec()).unwrap();
57
58        // Verify the body is valid JSON
59        let v: serde_json::Value = serde_json::from_str(&body_str).unwrap();
60        let names = v["names"].as_array().unwrap();
61
62        // Verify names are present and sorted
63        assert!(!names.is_empty());
64        let names_vec: Vec<String> = names
65            .iter()
66            .filter_map(|n| n.as_str().map(String::from))
67            .collect();
68        let mut sorted_names = names_vec.clone();
69        sorted_names.sort();
70        assert_eq!(names_vec, sorted_names, "env var names not sorted");
71
72        // Verify the secret value never appears in the response body
73        assert!(
74            !body_str.contains("s3cr3t"),
75            "secret value should not appear in response body"
76        );
77
78        // Verify the test env var name itself IS present
79        assert!(
80            names_vec.iter().any(|n| n == "FB_TEST_SECRET_VALUE_CANARY"),
81            "test env var name should be in response"
82        );
83    }
84}