Skip to main content

presolve_compiler/
environment_input.rs

1//! Explicit environment-input classification for application publication.
2//!
3//! This product never reads process state or files. Callers provide a named,
4//! already-authorized value map, and only `PRESOLVE_PUBLIC_*` values can enter
5//! the browser projection.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11pub const ENVIRONMENT_INPUT_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase", deny_unknown_fields)]
15pub struct EnvironmentInputManifestV1 {
16    pub schema_version: u32,
17    pub source_label: String,
18    pub browser_values: BTreeMap<String, String>,
19    pub server_value_names: Vec<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct EnvironmentInputErrorV1 {
24    pub code: &'static str,
25    pub message: String,
26}
27
28/// Classifies one explicit environment input map.
29///
30/// Values whose names begin exactly with `PRESOLVE_PUBLIC_` are browser
31/// eligible. Other valid environment names remain server-owned and are
32/// represented only by name, never by value, in this publication product.
33pub fn build_environment_input_manifest_v1(
34    source_label: &str,
35    values: &BTreeMap<String, String>,
36) -> Result<EnvironmentInputManifestV1, EnvironmentInputErrorV1> {
37    if source_label.is_empty() || source_label.contains('\0') || source_label.contains('\n') {
38        return Err(EnvironmentInputErrorV1 {
39            code: "PSENV1001_SOURCE_LABEL_INVALID",
40            message: source_label.into(),
41        });
42    }
43    let mut browser_values = BTreeMap::new();
44    let mut server_value_names = Vec::new();
45    for (name, value) in values {
46        if !is_environment_name(name) {
47            return Err(EnvironmentInputErrorV1 {
48                code: "PSENV1002_NAME_INVALID",
49                message: name.clone(),
50            });
51        }
52        if value.contains('\0') {
53            return Err(EnvironmentInputErrorV1 {
54                code: "PSENV1003_VALUE_INVALID",
55                message: name.clone(),
56            });
57        }
58        if name.starts_with("PRESOLVE_PUBLIC_") && name.len() > "PRESOLVE_PUBLIC_".len() {
59            browser_values.insert(name.clone(), value.clone());
60        } else {
61            server_value_names.push(name.clone());
62        }
63    }
64    Ok(EnvironmentInputManifestV1 {
65        schema_version: ENVIRONMENT_INPUT_SCHEMA_VERSION,
66        source_label: source_label.into(),
67        browser_values,
68        server_value_names,
69    })
70}
71
72#[must_use]
73pub fn environment_input_manifest_json_v1(value: &EnvironmentInputManifestV1) -> String {
74    serde_json::to_string_pretty(value).expect("environment input manifest serializes") + "\n"
75}
76
77/// Decodes a caller-selected manifest without consulting a dotenv file or
78/// process state. The decoded value receives the same classification checks as
79/// a manifest constructed from explicit values.
80pub fn environment_input_manifest_from_json_v1(
81    source: &str,
82) -> Result<EnvironmentInputManifestV1, EnvironmentInputErrorV1> {
83    let manifest = serde_json::from_str::<EnvironmentInputManifestV1>(source).map_err(|error| {
84        EnvironmentInputErrorV1 {
85            code: "PSENV1007_MANIFEST_JSON_INVALID",
86            message: error.to_string(),
87        }
88    })?;
89    validate_environment_input_manifest_v1(&manifest)?;
90    Ok(manifest)
91}
92
93/// Validates the full immutable manifest shape independently of its transport.
94pub fn validate_environment_input_manifest_v1(
95    manifest: &EnvironmentInputManifestV1,
96) -> Result<(), EnvironmentInputErrorV1> {
97    if manifest.schema_version != ENVIRONMENT_INPUT_SCHEMA_VERSION {
98        return Err(EnvironmentInputErrorV1 {
99            code: "PSENV1008_MANIFEST_SCHEMA_UNSUPPORTED",
100            message: manifest.schema_version.to_string(),
101        });
102    }
103    if manifest.source_label.is_empty()
104        || manifest.source_label.contains('\0')
105        || manifest.source_label.contains('\n')
106    {
107        return Err(EnvironmentInputErrorV1 {
108            code: "PSENV1001_SOURCE_LABEL_INVALID",
109            message: manifest.source_label.clone(),
110        });
111    }
112    for (name, value) in &manifest.browser_values {
113        if !is_environment_name(name)
114            || !name.starts_with("PRESOLVE_PUBLIC_")
115            || name.len() == "PRESOLVE_PUBLIC_".len()
116            || value.contains('\0')
117        {
118            return Err(EnvironmentInputErrorV1 {
119                code: "PSENV1009_MANIFEST_BROWSER_VALUES_INVALID",
120                message: name.clone(),
121            });
122        }
123    }
124    let mut previous = None;
125    for name in &manifest.server_value_names {
126        if !is_environment_name(name)
127            || name.starts_with("PRESOLVE_PUBLIC_")
128            || manifest.browser_values.contains_key(name)
129            || previous.is_some_and(|previous: &String| previous >= name)
130        {
131            return Err(EnvironmentInputErrorV1 {
132                code: "PSENV1010_MANIFEST_SERVER_NAMES_INVALID",
133                message: name.clone(),
134            });
135        }
136        previous = Some(name);
137    }
138    Ok(())
139}
140
141fn is_environment_name(value: &str) -> bool {
142    value
143        .chars()
144        .next()
145        .is_some_and(|character| character.is_ascii_uppercase() || character == '_')
146        && value.chars().all(|character| {
147            character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
148        })
149}
150
151#[cfg(test)]
152mod tests {
153    use std::collections::BTreeMap;
154
155    use super::{
156        build_environment_input_manifest_v1, environment_input_manifest_from_json_v1,
157        environment_input_manifest_json_v1,
158    };
159
160    #[test]
161    fn publishes_only_prefixed_values_and_never_server_values() {
162        let values = BTreeMap::from([
163            ("PRESOLVE_PUBLIC_NAME".into(), "Presolve".into()),
164            ("DATABASE_URL".into(), "postgres://secret".into()),
165        ]);
166        let manifest = build_environment_input_manifest_v1(".env", &values).unwrap();
167        assert_eq!(manifest.browser_values["PRESOLVE_PUBLIC_NAME"], "Presolve");
168        assert_eq!(manifest.server_value_names, ["DATABASE_URL"]);
169        assert!(!environment_input_manifest_json_v1(&manifest).contains("postgres://secret"));
170    }
171
172    #[test]
173    fn rejects_ambient_or_malformed_name_forms() {
174        let values = BTreeMap::from([("process.env.SECRET".into(), "value".into())]);
175        assert_eq!(
176            build_environment_input_manifest_v1(".env", &values)
177                .unwrap_err()
178                .code,
179            "PSENV1002_NAME_INVALID"
180        );
181    }
182
183    #[test]
184    fn decodes_only_canonical_explicit_manifest_json() {
185        let manifest = build_environment_input_manifest_v1(
186            ".env.production",
187            &BTreeMap::from([
188                ("PRESOLVE_PUBLIC_NAME".into(), "Presolve".into()),
189                ("DATABASE_URL".into(), "postgres://secret".into()),
190            ]),
191        )
192        .unwrap();
193        let decoded =
194            environment_input_manifest_from_json_v1(&environment_input_manifest_json_v1(&manifest))
195                .unwrap();
196        assert_eq!(decoded, manifest);
197        assert_eq!(
198            environment_input_manifest_from_json_v1(
199                r#"{"schemaVersion":1,"sourceLabel":".env","browserValues":{"DATABASE_URL":"secret"},"serverValueNames":[]}"#
200            )
201            .unwrap_err()
202            .code,
203            "PSENV1009_MANIFEST_BROWSER_VALUES_INVALID"
204        );
205    }
206}