Skip to main content

presolve_compiler/
environment_read_lowering.rs

1//! Manifest-backed lowering for TypeScript-proven V2 environment reads.
2
3use 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/// One browser-safe environment value joined to its exact source and
16/// TypeScript-authoritative framework identity.
17#[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/// Lowers only exact authority evidence.  Every rejected input omits a read
66/// record, so no browser value is exposed while diagnostics remain stable.
67#[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}