presolve_compiler/
environment_input.rs1use 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
28pub 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
77pub 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
93pub 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}