1use std::path::PathBuf;
4
5use presolve_parser::{ParsedCallArgument, ParsedFile};
6use serde::Serialize;
7
8use crate::{
9 AuthoredSourceRangeV1, EnvironmentInputManifestV1, ResolvedEnvironmentPublicReadV1,
10 ResolvedIntrinsicIdentityV1, ENVIRONMENT_INPUT_SCHEMA_VERSION,
11};
12
13pub const ENVIRONMENT_READ_LOWERING_SCHEMA_VERSION: u32 = 1;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct EnvironmentReadRecordV1 {
19 pub source_path: PathBuf,
20 pub call_source: AuthoredSourceRangeV1,
21 pub name_source: AuthoredSourceRangeV1,
22 pub name: String,
23 pub browser_value: String,
24 pub environment_public_identity: ResolvedIntrinsicIdentityV1,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EnvironmentReadDiagnosticCodeV1 {
30 InvalidCallArgument,
31 ManifestRequired,
32 ManifestSchemaUnsupported,
33 ServerValueForbidden,
34 NameNotBrowserPublic,
35 ValueNotDeclared,
36}
37
38impl EnvironmentReadDiagnosticCodeV1 {
39 #[must_use]
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::InvalidCallArgument => "PSENV1101_INVALID_CALL_ARGUMENT",
43 Self::ManifestRequired => "PSENV1102_MANIFEST_REQUIRED",
44 Self::ManifestSchemaUnsupported => "PSENV1103_MANIFEST_SCHEMA_UNSUPPORTED",
45 Self::ServerValueForbidden => "PSENV1104_SERVER_VALUE_FORBIDDEN",
46 Self::NameNotBrowserPublic => "PSENV1105_NAME_NOT_BROWSER_PUBLIC",
47 Self::ValueNotDeclared => "PSENV1106_VALUE_NOT_DECLARED",
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct EnvironmentReadDiagnosticV1 {
54 pub code: EnvironmentReadDiagnosticCodeV1,
55 pub call_source: AuthoredSourceRangeV1,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct EnvironmentReadLoweringV1 {
60 pub schema_version: u32,
61 pub reads: Vec<EnvironmentReadRecordV1>,
62 pub diagnostics: Vec<EnvironmentReadDiagnosticV1>,
63}
64
65#[must_use]
68pub fn lower_environment_reads_v1(
69 parsed: &ParsedFile,
70 evidence: impl IntoIterator<Item = ResolvedEnvironmentPublicReadV1>,
71 manifest: Option<&EnvironmentInputManifestV1>,
72) -> EnvironmentReadLoweringV1 {
73 let mut reads = Vec::new();
74 let mut diagnostics = Vec::new();
75 for resolved in evidence {
76 let Some(call) = parsed.call_expressions.iter().find(|call| {
77 call.span.start == resolved.call_source.start
78 && call.span.end == resolved.call_source.end
79 }) else {
80 diagnostics.push(EnvironmentReadDiagnosticV1 {
81 code: EnvironmentReadDiagnosticCodeV1::InvalidCallArgument,
82 call_source: resolved.call_source,
83 });
84 continue;
85 };
86 let [ParsedCallArgument::StringLiteral { value, span }] = call.arguments.as_slice() else {
87 diagnostics.push(EnvironmentReadDiagnosticV1 {
88 code: EnvironmentReadDiagnosticCodeV1::InvalidCallArgument,
89 call_source: resolved.call_source,
90 });
91 continue;
92 };
93 let Some(manifest) = manifest else {
94 diagnostics.push(EnvironmentReadDiagnosticV1 {
95 code: EnvironmentReadDiagnosticCodeV1::ManifestRequired,
96 call_source: resolved.call_source,
97 });
98 continue;
99 };
100 if manifest.schema_version != ENVIRONMENT_INPUT_SCHEMA_VERSION {
101 diagnostics.push(EnvironmentReadDiagnosticV1 {
102 code: EnvironmentReadDiagnosticCodeV1::ManifestSchemaUnsupported,
103 call_source: resolved.call_source,
104 });
105 continue;
106 }
107 if manifest.server_value_names.binary_search(value).is_ok() {
108 diagnostics.push(EnvironmentReadDiagnosticV1 {
109 code: EnvironmentReadDiagnosticCodeV1::ServerValueForbidden,
110 call_source: resolved.call_source,
111 });
112 continue;
113 }
114 if !value.starts_with("PRESOLVE_PUBLIC_") || value.len() == "PRESOLVE_PUBLIC_".len() {
115 diagnostics.push(EnvironmentReadDiagnosticV1 {
116 code: EnvironmentReadDiagnosticCodeV1::NameNotBrowserPublic,
117 call_source: resolved.call_source,
118 });
119 continue;
120 }
121 let Some(browser_value) = manifest.browser_values.get(value) else {
122 diagnostics.push(EnvironmentReadDiagnosticV1 {
123 code: EnvironmentReadDiagnosticCodeV1::ValueNotDeclared,
124 call_source: resolved.call_source,
125 });
126 continue;
127 };
128 reads.push(EnvironmentReadRecordV1 {
129 source_path: parsed.path.clone(),
130 call_source: resolved.call_source,
131 name_source: range_from_parser_span(*span),
132 name: value.clone(),
133 browser_value: browser_value.clone(),
134 environment_public_identity: resolved.environment_public_identity,
135 });
136 }
137 reads.sort_by_key(|read| (read.source_path.clone(), read.call_source.start));
138 diagnostics.sort_by_key(|diagnostic| {
139 (
140 diagnostic.call_source.start,
141 diagnostic.call_source.end,
142 diagnostic.code,
143 )
144 });
145 EnvironmentReadLoweringV1 {
146 schema_version: ENVIRONMENT_READ_LOWERING_SCHEMA_VERSION,
147 reads,
148 diagnostics,
149 }
150}
151
152fn range_from_parser_span(span: presolve_parser::SourceSpan) -> AuthoredSourceRangeV1 {
153 AuthoredSourceRangeV1 {
154 start: span.start,
155 end: span.end,
156 line: span.line,
157 column: span.column,
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use std::collections::BTreeMap;
164
165 use presolve_parser::parse_file;
166
167 use crate::{build_environment_input_manifest_v1, ResolvedEnvironmentPublicReadV1};
168
169 use super::{
170 lower_environment_reads_v1, EnvironmentReadDiagnosticCodeV1,
171 ENVIRONMENT_READ_LOWERING_SCHEMA_VERSION,
172 };
173
174 fn evidence(
175 parsed: &presolve_parser::ParsedFile,
176 index: usize,
177 ) -> ResolvedEnvironmentPublicReadV1 {
178 let call = &parsed.call_expressions[index];
179 ResolvedEnvironmentPublicReadV1 {
180 call_source: crate::AuthoredSourceRangeV1 {
181 start: call.span.start,
182 end: call.span.end,
183 line: call.span.line,
184 column: call.span.column,
185 },
186 environment_public_identity: crate::ResolvedIntrinsicIdentityV1 {
187 name: "public".into(),
188 flags: 32,
189 declaration_modules: vec!["presolve".into()],
190 },
191 }
192 }
193
194 #[test]
195 fn joins_only_literal_public_reads_to_manifest_values() {
196 let source = r#"
197const appName = runtimeEnvironment.public("PRESOLVE_PUBLIC_APP_NAME");
198const database = runtimeEnvironment.public("DATABASE_URL");
199const dynamic = runtimeEnvironment.public(name);
200const unprefixed = runtimeEnvironment.public("UNDECLARED_VALUE");
201const missing = runtimeEnvironment.public("PRESOLVE_PUBLIC_MISSING");
202"#;
203 let parsed = parse_file("src/environment.ts", source);
204 let manifest = build_environment_input_manifest_v1(
205 ".env",
206 &BTreeMap::from([
207 ("PRESOLVE_PUBLIC_APP_NAME".into(), "Presolve".into()),
208 ("DATABASE_URL".into(), "postgres://secret".into()),
209 ]),
210 )
211 .unwrap();
212 let lowered = lower_environment_reads_v1(
213 &parsed,
214 [
215 evidence(&parsed, 0),
216 evidence(&parsed, 1),
217 evidence(&parsed, 2),
218 evidence(&parsed, 3),
219 evidence(&parsed, 4),
220 ],
221 Some(&manifest),
222 );
223
224 assert_eq!(
225 lowered.schema_version,
226 ENVIRONMENT_READ_LOWERING_SCHEMA_VERSION
227 );
228 assert_eq!(lowered.reads.len(), 1);
229 assert_eq!(lowered.reads[0].name, "PRESOLVE_PUBLIC_APP_NAME");
230 assert_eq!(lowered.reads[0].browser_value, "Presolve");
231 assert!(!format!("{lowered:?}").contains("postgres://secret"));
232 assert_eq!(
233 lowered
234 .diagnostics
235 .iter()
236 .map(|diagnostic| diagnostic.code)
237 .collect::<Vec<_>>(),
238 vec![
239 EnvironmentReadDiagnosticCodeV1::ServerValueForbidden,
240 EnvironmentReadDiagnosticCodeV1::InvalidCallArgument,
241 EnvironmentReadDiagnosticCodeV1::NameNotBrowserPublic,
242 EnvironmentReadDiagnosticCodeV1::ValueNotDeclared,
243 ]
244 );
245 }
246
247 #[test]
248 fn fails_closed_without_a_current_manifest() {
249 let parsed = parse_file(
250 "src/environment.ts",
251 "const appName = runtimeEnvironment.public(\"PRESOLVE_PUBLIC_APP_NAME\");",
252 );
253 let lowered = lower_environment_reads_v1(&parsed, [evidence(&parsed, 0)], None);
254 assert!(lowered.reads.is_empty());
255 assert_eq!(
256 lowered.diagnostics[0].code,
257 EnvironmentReadDiagnosticCodeV1::ManifestRequired
258 );
259 assert_eq!(
260 lowered.diagnostics[0].code.as_str(),
261 "PSENV1102_MANIFEST_REQUIRED"
262 );
263 }
264}