Skip to main content

rift_ejs/
lib.rs

1//! The EJS template subset Rift's config loader evaluates, shared by the engine and `rift-lint`
2//! (issue #1108) so both read a templated config the same way.
3//!
4//! The four supported tags and every refusal are documented on [`render`]. This crate has no rift
5//! dependencies, which is what lets `rift-lint` use it: `rift-http-proxy` dev-depends on `rift-lint`.
6
7use regex::Regex;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11// Fixed EJS tag patterns (issue #560): compile once at first use rather than on every
12// `render` call — that runs per config file at startup, on every `POST /admin/reload`, and
13// from the script CLI. All are compile-time-constant patterns, so a compile failure is a
14// programming error caught immediately by tests, not a data-dependent runtime error.
15
16/// `<% include 'path' %>` — quoted or bare path.
17static EJS_INCLUDE_RE: LazyLock<Regex> = LazyLock::new(|| {
18    Regex::new(r#"<%\s*include\s+['"]?([^'">\s]+)['"]?\s*%>"#)
19        .expect("EJS include pattern is a valid constant regex")
20});
21
22/// `<%- stringify('relative/path') %>` (issue #355 Item 7).
23static EJS_STRINGIFY_RE: LazyLock<Regex> = LazyLock::new(|| {
24    Regex::new(r#"<%-\s*stringify\(\s*['"]([^'"]+)['"]\s*\)\s*%>"#)
25        .expect("EJS stringify pattern is a valid constant regex")
26});
27
28/// `<%= expr %>` expression tag.
29static EJS_EXPR_RE: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r"<%=\s*(.*?)\s*%>").expect("EJS expression pattern is a valid constant regex")
31});
32
33/// The only supported expression body: `process.env.VAR` with an optional `|| 'default'`.
34static EJS_ENV_VAR_RE: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r#"^process\.env\.([A-Za-z_][A-Za-z0-9_]*)(?:\s*\|\|\s*['"]([^'"]*)['"]\s*)?$"#)
36        .expect("EJS env-var pattern is a valid constant regex")
37});
38
39/// Whether EJS tags that read the local filesystem are honoured.
40///
41/// `--configfile` documents are authored by someone who already has filesystem access, so
42/// `<% include %>` / `<%- stringify %>` are resolved verbatim — the same rationale that makes
43/// rift-mock-core's `ScriptBaseDir::ConfigRelative` unrestricted. A document fetched over the network (U-12's
44/// `https:` source) has no such author, so those two tags are refused rather than resolved:
45/// honouring them would let whoever serves the document read arbitrary local files. `<%=
46/// process.env.X %>` still substitutes in both — env is deployment config the operator chose to
47/// expose to their own process.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum FileAccess {
50    /// Local document: `include` and `stringify` resolve against the document's directory.
51    Allowed,
52    /// Remote document: `include` and `stringify` are a load error naming the tag.
53    Denied,
54}
55
56/// A document with its tags rendered.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Rendered {
59    pub text: String,
60    /// Each `<%= process.env.VAR %>` whose variable could not be substituted: unset with no default,
61    /// or set to a value that is not valid Unicode.
62    pub unset_env: Vec<UnsetEnv>,
63}
64
65/// An environment variable a tag read but could not substitute.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct UnsetEnv {
68    pub name: String,
69    /// Where the tag is, as `at <file>:<line>` (with the including file for an included one).
70    pub place: String,
71    pub reason: EnvProblem,
72}
73
74/// Why a variable could not be substituted (issue #1116).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum EnvProblem {
77    /// Not set, and the tag gives no default, so the tag renders empty.
78    Unset,
79    /// Set, but the value is not valid Unicode, so the tag renders its default, or empty without one.
80    NotUnicode,
81}
82
83impl UnsetEnv {
84    /// One sentence naming the variable, the tag and what it rendered, shared by the engine's
85    /// warning and `rift-lint`'s `W013` so both describe the condition the same way.
86    #[must_use]
87    pub fn describe(&self) -> String {
88        match self.reason {
89            EnvProblem::Unset => format!(
90                "`{}` is unset and the tag {} gives no default, so it renders empty",
91                self.name, self.place
92            ),
93            EnvProblem::NotUnicode => format!(
94                "`{}` is set but its value is not valid Unicode, so the tag {} renders its default, \
95                 or empty without one",
96                self.name, self.place
97            ),
98        }
99    }
100}
101
102/// Why a document could not be rendered. Each message names the tag or file and where it is.
103#[derive(Debug, thiserror::Error)]
104pub enum EjsError {
105    /// A tag the loader does not evaluate (issue #1095).
106    #[error("{0}")]
107    UnsupportedTag(String),
108    /// An `include` or `stringify` in a document fetched from somewhere other than local disk.
109    #[error("{0}")]
110    LocalFileRefused(String),
111    #[error("EJS include file '{file}' not found ({}): {io}", path.display())]
112    Include {
113        file: String,
114        path: PathBuf,
115        io: std::io::Error,
116    },
117    #[error("EJS stringify file '{file}' not found ({}): {io}", path.display())]
118    Stringify {
119        file: String,
120        path: PathBuf,
121        io: std::io::Error,
122    },
123    #[error("failed to JSON-encode stringify file '{file}': {json}")]
124    Encode {
125        file: String,
126        json: serde_json::Error,
127    },
128}
129
130/// True when `content` holds anything that looks like a tag, so [`render`] would change or refuse it.
131#[must_use]
132pub fn has_tags(content: &str) -> bool {
133    content.contains("<%")
134}
135
136/// Pre-process EJS tokens in a config file before JSON/YAML parsing.
137///
138/// Handles the patterns emitted by Mountebank and compatible tooling:
139/// - `<% include 'path' %>` — inline the referenced file (relative to the config file)
140/// - `<%- stringify('path') %>` — inline a file's rendered contents, escaped for a JSON string
141/// - `<%= process.env.VAR %>` — substitute with the env var value (empty string if unset)
142/// - `<%= process.env.VAR || 'default' %>` — substitute with env var or the literal default
143///
144/// Any other tag — another `<%= expr %>`, a `<% statement %>`, `<%- expr %>`, `<%# comment %>`, a
145/// `<%` with no closing `%>` — fails the load, naming the tag and where it is (issue #1095).
146/// Stripping it would load a config that silently differs from the file.
147///
148/// Includes are expanded first, so an included file is templated like the document. Every other
149/// tag is then substituted by its own span in one pass, so text a substitution inserts — an env
150/// var's value, a stringified file — is never scanned again as if it were template.
151///
152/// A `<%= process.env.VAR %>` whose variable is unset renders empty, as Mountebank's does; each one
153/// is listed in [`Rendered::unset_env`] so a caller can say so. One with a `|| 'default'` is not. A
154/// variable set to a value that is not valid Unicode is listed either way, because the value the
155/// author set is ignored.
156///
157/// # Errors
158///
159/// [`EjsError`] names the first tag that cannot be rendered and where it is.
160pub fn render(
161    content: &str,
162    config_path: &Path,
163    file_access: FileAccess,
164) -> Result<Rendered, EjsError> {
165    if !has_tags(content) {
166        return Ok(Rendered {
167            text: content.to_string(),
168            unset_env: Vec::new(),
169        });
170    }
171
172    // Fail closed before any resolution: a remote document that names a local file is refused
173    // outright, naming the tag.
174    if file_access == FileAccess::Denied {
175        for (re, tag) in [
176            (&*EJS_INCLUDE_RE, "<% include ... %>"),
177            (&*EJS_STRINGIFY_RE, "<%- stringify(...) %>"),
178        ] {
179            if let Some(cap) = re.captures(content) {
180                return Err(EjsError::LocalFileRefused(format!(
181                    "`{tag}` reads a local file and is not honoured in a document fetched from \
182                     {} — it names '{}'. Only local `--configfile` documents may include local \
183                     files; use `--configfile` if the template must, or inline the content at \
184                     the source.",
185                    config_path.display(),
186                    &cap[1],
187                )));
188            }
189        }
190    }
191
192    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
193    let expanded = expand_includes(content, config_dir)?;
194    let locate = |offset: usize| expanded.locate(offset, content, config_path);
195    let mut unset_env = Vec::new();
196    let text = render_tags(
197        &expanded.text,
198        TagScope::Document,
199        config_dir,
200        file_access,
201        &locate,
202        &mut unset_env,
203    )?;
204    Ok(Rendered { text, unset_env })
205}
206
207/// A document with its `<% include %>` tags replaced by the files they name.
208#[derive(Debug)]
209struct ExpandedDocument {
210    text: String,
211    includes: Vec<IncludedSpan>,
212}
213
214/// Where one included file landed in [`ExpandedDocument::text`].
215#[derive(Debug)]
216struct IncludedSpan {
217    /// The included file's bytes within the expanded text.
218    range: std::ops::Range<usize>,
219    /// The include tag's position and length in the original document.
220    tag_offset: usize,
221    tag_len: usize,
222    /// The path as the tag wrote it.
223    file: String,
224}
225
226impl ExpandedDocument {
227    /// Describe `offset` in the expanded text as a place a reader can open: a line of the original
228    /// document, or a line of the included file together with the line that included it.
229    fn locate(&self, offset: usize, original: &str, config_path: &Path) -> String {
230        let mut inserted = 0;
231        let mut removed = 0;
232        for span in &self.includes {
233            if span.range.contains(&offset) {
234                let local = &self.text[span.range.clone()];
235                return format!(
236                    "at {}:{}, included at {}:{}",
237                    span.file,
238                    line_of(local, offset - span.range.start),
239                    config_path.display(),
240                    line_of(original, span.tag_offset)
241                );
242            }
243            if span.range.end <= offset {
244                inserted += span.range.len();
245                removed += span.tag_len;
246            }
247        }
248        let original_offset = offset + removed - inserted;
249        format!(
250            "at {}:{}",
251            config_path.display(),
252            line_of(original, original_offset)
253        )
254    }
255}
256
257fn line_of(text: &str, offset: usize) -> usize {
258    text[..offset].matches('\n').count() + 1
259}
260
261fn expand_includes(content: &str, config_dir: &Path) -> Result<ExpandedDocument, EjsError> {
262    let mut text = String::with_capacity(content.len());
263    let mut includes = Vec::new();
264    let mut last = 0;
265    for cap in EJS_INCLUDE_RE.captures_iter(content) {
266        let (Some(full), Some(include_path)) = (cap.get(0), cap.get(1)) else {
267            continue;
268        };
269        text.push_str(&content[last..full.start()]);
270        let abs_path = config_dir.join(include_path.as_str());
271        let included = std::fs::read_to_string(&abs_path).map_err(|io| EjsError::Include {
272            file: include_path.as_str().to_string(),
273            path: abs_path.clone(),
274            io,
275        })?;
276        let start = text.len();
277        text.push_str(&included);
278        includes.push(IncludedSpan {
279            range: start..text.len(),
280            tag_offset: full.start(),
281            tag_len: full.len(),
282            file: include_path.as_str().to_string(),
283        });
284        last = full.end();
285    }
286    text.push_str(&content[last..]);
287    Ok(ExpandedDocument { text, includes })
288}
289
290/// What template text is being rendered, which decides the tags it may hold.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292enum TagScope {
293    /// The document, includes already expanded.
294    Document,
295    /// A file a `<%- stringify %>` tag inlines. It is rendered like the document before it is
296    /// escaped (Mountebank's formatter does the same), but it may not include or stringify again.
297    Stringified,
298}
299
300/// Substitute every tag in `text` by its own span, refusing the first one that is not evaluated.
301/// `locate` turns an offset in `text` into the place named in an error.
302fn render_tags(
303    text: &str,
304    scope: TagScope,
305    config_dir: &Path,
306    file_access: FileAccess,
307    locate: &dyn Fn(usize) -> String,
308    unset_env: &mut Vec<UnsetEnv>,
309) -> Result<String, EjsError> {
310    let mut out = String::with_capacity(text.len());
311    let mut from = 0;
312    while let Some(found) = text[from..].find("<%") {
313        let offset = from + found;
314        out.push_str(&text[from..offset]);
315        let Some(close) = text[offset + 2..].find("%>") else {
316            let tag = UnsupportedTag {
317                text: &text[offset..],
318                terminated: false,
319                note: "",
320            };
321            return Err(EjsError::UnsupportedTag(
322                tag.message(&locate(offset), file_access),
323            ));
324        };
325        let end = offset + 2 + close + 2;
326        let tag = &text[offset..end];
327
328        if let Some(expression) = env_expression(tag) {
329            if let Some((name, reason)) = expression.problem {
330                unset_env.push(UnsetEnv {
331                    name: name.to_string(),
332                    place: locate(offset),
333                    reason,
334                });
335            }
336            out.push_str(&expression.value);
337        } else if let Some(rel_path) = whole_tag_capture(&EJS_STRINGIFY_RE, tag)
338            && scope == TagScope::Document
339        {
340            out.push_str(&stringified(
341                rel_path,
342                config_dir,
343                file_access,
344                &|inner| format!("{}, stringified {}", inner, locate(offset)),
345                unset_env,
346            )?);
347        } else {
348            let note = if whole_tag_capture(&EJS_INCLUDE_RE, tag).is_some() {
349                " (an include inside an included or stringified file is not evaluated)"
350            } else if whole_tag_capture(&EJS_STRINGIFY_RE, tag).is_some() {
351                " (a stringify inside a stringified file is not evaluated)"
352            } else {
353                ""
354            };
355            let tag = UnsupportedTag {
356                text: tag,
357                terminated: true,
358                note,
359            };
360            return Err(EjsError::UnsupportedTag(
361                tag.message(&locate(offset), file_access),
362            ));
363        }
364        from = end;
365    }
366    out.push_str(&text[from..]);
367    Ok(out)
368}
369
370/// The file `rel_path` names, rendered and escaped for use inside a JSON string (issue #355 Item
371/// 7). The template supplies the surrounding quotes (`"inject": "<%- stringify('inject.js') %>"`),
372/// so only the escaped inner content is returned. `outer` wraps a place in the file with where the
373/// stringify tag is.
374fn stringified(
375    rel_path: &str,
376    config_dir: &Path,
377    file_access: FileAccess,
378    outer: &dyn Fn(String) -> String,
379    unset_env: &mut Vec<UnsetEnv>,
380) -> Result<String, EjsError> {
381    let abs_path = config_dir.join(rel_path);
382    let contents = std::fs::read_to_string(&abs_path).map_err(|io| EjsError::Stringify {
383        file: rel_path.to_string(),
384        path: abs_path.clone(),
385        io,
386    })?;
387    let locate = |offset: usize| outer(format!("at {rel_path}:{}", line_of(&contents, offset)));
388    let rendered = render_tags(
389        &contents,
390        TagScope::Stringified,
391        config_dir,
392        file_access,
393        &locate,
394        unset_env,
395    )?;
396    let json_quoted = serde_json::to_string(&rendered).map_err(|json| EjsError::Encode {
397        file: rel_path.to_string(),
398        json,
399    })?;
400    // `to_string` of a `String` always yields a quoted JSON string.
401    Ok(json_quoted[1..json_quoted.len() - 1].to_string())
402}
403
404/// `re`'s first capture group when `re` matches the whole of `tag`, not just part of it.
405fn whole_tag_capture<'t>(re: &Regex, tag: &'t str) -> Option<&'t str> {
406    let cap = re.captures(tag)?;
407    let whole = cap.get(0)?;
408    if whole.range() != (0..tag.len()) {
409        return None;
410    }
411    cap.get(1).map(|m| m.as_str())
412}
413
414/// A rendered `<%= process.env.VAR %>` tag.
415struct EnvExpression<'t> {
416    value: String,
417    /// The variable and why it could not be substituted, when it could not.
418    problem: Option<(&'t str, EnvProblem)>,
419}
420
421/// The value of a `<%= process.env.VAR %>` / `<%= process.env.VAR || 'default' %>` tag, or `None`
422/// when `tag` is not one.
423fn env_expression(tag: &str) -> Option<EnvExpression<'_>> {
424    let body = whole_tag_capture(&EJS_EXPR_RE, tag)?.trim();
425    let env_cap = EJS_ENV_VAR_RE.captures(body)?;
426    let var_name = env_cap.get(1)?.as_str();
427    let default = env_cap.get(2).map(|m| m.as_str());
428    Some(match (std::env::var(var_name), default) {
429        (Ok(value), _) => EnvExpression {
430            value,
431            problem: None,
432        },
433        (Err(std::env::VarError::NotPresent), Some(default)) => EnvExpression {
434            value: default.to_string(),
435            problem: None,
436        },
437        (Err(std::env::VarError::NotPresent), None) => EnvExpression {
438            value: String::new(),
439            problem: Some((var_name, EnvProblem::Unset)),
440        },
441        (Err(std::env::VarError::NotUnicode(_)), default) => EnvExpression {
442            value: default.unwrap_or_default().to_string(),
443            problem: Some((var_name, EnvProblem::NotUnicode)),
444        },
445    })
446}
447
448/// An EJS tag the preprocessor found and does not evaluate.
449#[derive(Debug)]
450struct UnsupportedTag<'a> {
451    /// The tag as written, from `<%` through `%>` (or to the end of the text when unterminated).
452    text: &'a str,
453    terminated: bool,
454    /// Why a tag that looks supported is not, where that applies.
455    note: &'static str,
456}
457
458impl UnsupportedTag<'_> {
459    const MAX_SHOWN_CHARS: usize = 80;
460
461    /// The load error, with the tag found `place` (from [`ExpandedDocument::locate`]).
462    fn message(&self, place: &str, file_access: FileAccess) -> String {
463        let shown: String = if self.text.chars().count() > Self::MAX_SHOWN_CHARS {
464            let head: String = self.text.chars().take(Self::MAX_SHOWN_CHARS - 1).collect();
465            format!("{head}…")
466        } else {
467            self.text.to_string()
468        };
469        let unterminated = if self.terminated {
470            ""
471        } else {
472            " (no closing `%>`)"
473        };
474        let note = self.note;
475        match file_access {
476            FileAccess::Allowed => format!(
477                "unsupported EJS tag `{shown}`{unterminated} {place}{note}, so the file was not \
478                 loaded. Only `<%= process.env.VAR %>`, `<%= process.env.VAR || 'default' %>`, \
479                 `<% include 'file' %>` and `<%- stringify('file') %>` are evaluated. If the tag \
480                 is meant literally, load the file with --no-parse (a --configfile or file: \
481                 source)."
482            ),
483            FileAccess::Denied => format!(
484                "unsupported EJS tag `{shown}`{unterminated} {place}{note}, so the document was not \
485                 loaded. Only `<%= process.env.VAR %>` and `<%= process.env.VAR || 'default' %>` are \
486                 evaluated in a fetched document, and it is always preprocessed: remove the tag at \
487                 the source."
488            ),
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn ejs_statics_match_their_tags() {
499        assert_eq!(
500            EJS_INCLUDE_RE
501                .captures(r#"<% include 'a/b.json' %>"#)
502                .unwrap()[1]
503                .to_string(),
504            "a/b.json"
505        );
506        assert_eq!(
507            EJS_INCLUDE_RE.captures("<% include bare.json %>").unwrap()[1].to_string(),
508            "bare.json"
509        );
510
511        assert_eq!(
512            EJS_STRINGIFY_RE
513                .captures(r#"<%- stringify('inject.js') %>"#)
514                .unwrap()[1]
515                .to_string(),
516            "inject.js"
517        );
518
519        assert_eq!(
520            EJS_EXPR_RE.captures("<%= process.env.HOST %>").unwrap()[1].to_string(),
521            "process.env.HOST"
522        );
523
524        let env_cap = EJS_ENV_VAR_RE
525            .captures("process.env.PORT || '4545'")
526            .unwrap();
527        assert_eq!(env_cap[1].to_string(), "PORT");
528        assert_eq!(env_cap[2].to_string(), "4545");
529        assert!(
530            EJS_ENV_VAR_RE
531                .captures("process.env.HOST")
532                .unwrap()
533                .get(2)
534                .is_none()
535        );
536        assert!(EJS_ENV_VAR_RE.captures("someOtherExpr()").is_none());
537    }
538
539    const UNSET: &str = "RIFT_EJS_TEST_1108_NEVER_SET";
540
541    #[test]
542    fn an_unset_variable_without_a_default_renders_empty_and_is_reported() {
543        assert!(
544            std::env::var(UNSET).is_err(),
545            "{UNSET} must not be set in the test environment"
546        );
547        let rendered = render(
548            &format!("{{\n\"body\": \"<%= process.env.{UNSET} %>\"}}"),
549            Path::new("/cfg/imposters.json"),
550            FileAccess::Allowed,
551        )
552        .expect("renders");
553        assert_eq!(rendered.text, "{\n\"body\": \"\"}");
554        assert_eq!(
555            rendered.unset_env,
556            vec![UnsetEnv {
557                name: UNSET.to_string(),
558                place: "at /cfg/imposters.json:2".to_string(),
559                reason: EnvProblem::Unset,
560            }]
561        );
562        assert_eq!(
563            rendered.unset_env[0].describe(),
564            format!(
565                "`{UNSET}` is unset and the tag at /cfg/imposters.json:2 gives no default, so it \
566                 renders empty"
567            )
568        );
569    }
570
571    // Issue #1116: a variable that is set but not valid Unicode was reported as "unset" without a
572    // default, and not reported at all with one, though the author set a value that was ignored.
573    #[cfg(unix)]
574    #[test]
575    fn a_set_but_non_unicode_variable_is_reported_as_such() {
576        use std::os::unix::ffi::OsStrExt;
577        const NOT_UNICODE: &str = "RIFT_EJS_TEST_1116_NOT_UNICODE";
578        // Safety: a name no other test reads, set once and never removed.
579        unsafe { std::env::set_var(NOT_UNICODE, std::ffi::OsStr::from_bytes(b"\xff\xfe")) };
580
581        for (tag, rendered_text) in [
582            (format!("<%= process.env.{NOT_UNICODE} %>"), ""),
583            (
584                format!("<%= process.env.{NOT_UNICODE} || 'fallback' %>"),
585                "fallback",
586            ),
587        ] {
588            let rendered =
589                render(&tag, Path::new("/cfg/a.json"), FileAccess::Allowed).expect("renders");
590            assert_eq!(rendered.text, rendered_text, "{tag}");
591            assert_eq!(
592                rendered.unset_env,
593                vec![UnsetEnv {
594                    name: NOT_UNICODE.to_string(),
595                    place: "at /cfg/a.json:1".to_string(),
596                    reason: EnvProblem::NotUnicode,
597                }],
598                "{tag}"
599            );
600            assert!(
601                rendered.unset_env[0]
602                    .describe()
603                    .contains("is set but its value is not valid Unicode"),
604                "{}",
605                rendered.unset_env[0].describe()
606            );
607        }
608    }
609
610    #[test]
611    fn a_default_is_used_and_not_reported() {
612        let rendered = render(
613            &format!("{{\"port\": <%= process.env.{UNSET} || '4545' %>}}"),
614            Path::new("imposters.json"),
615            FileAccess::Allowed,
616        )
617        .expect("renders");
618        assert_eq!(rendered.text, "{\"port\": 4545}");
619        assert!(rendered.unset_env.is_empty(), "{:?}", rendered.unset_env);
620
621        let explicit_empty = render(
622            &format!("<%= process.env.{UNSET} || '' %>"),
623            Path::new("imposters.json"),
624            FileAccess::Allowed,
625        )
626        .expect("renders");
627        assert_eq!(explicit_empty.text, "");
628        assert!(
629            explicit_empty.unset_env.is_empty(),
630            "an explicit empty default is chosen"
631        );
632    }
633
634    #[test]
635    fn a_set_variable_is_substituted_and_not_reported() {
636        let path = std::env::var("PATH").expect("PATH is set wherever tests run");
637        let rendered = render(
638            "<%= process.env.PATH %>",
639            Path::new("x.json"),
640            FileAccess::Allowed,
641        )
642        .expect("renders");
643        assert_eq!(rendered.text, path);
644        assert!(rendered.unset_env.is_empty());
645    }
646
647    #[test]
648    fn an_unset_variable_inside_a_stringified_file_is_reported_with_both_places() {
649        let dir = std::env::temp_dir().join(format!("rift-ejs-1108-{}", std::process::id()));
650        std::fs::create_dir_all(&dir).expect("temp dir");
651        std::fs::write(
652            dir.join("inject.js"),
653            format!("x\n<%= process.env.{UNSET} %>"),
654        )
655        .expect("write stringified file");
656        let config = dir.join("imposters.json");
657        let rendered = render(
658            "{\"inject\": \"<%- stringify('inject.js') %>\"}",
659            &config,
660            FileAccess::Allowed,
661        )
662        .expect("renders");
663        std::fs::remove_dir_all(&dir).ok();
664        assert_eq!(rendered.unset_env.len(), 1, "{:?}", rendered.unset_env);
665        assert_eq!(
666            rendered.unset_env[0].place,
667            format!("at inject.js:2, stringified at {}:1", config.display())
668        );
669    }
670
671    #[test]
672    fn errors_keep_the_loader_messages() {
673        let unsupported = render(
674            "<% for (x) %>",
675            Path::new("/cfg/a.json"),
676            FileAccess::Allowed,
677        )
678        .expect_err("unsupported tag");
679        assert!(matches!(unsupported, EjsError::UnsupportedTag(_)));
680        assert!(
681            unsupported.to_string().starts_with(
682                "unsupported EJS tag `<% for (x) %>` at /cfg/a.json:1, so the file was not loaded."
683            ),
684            "{unsupported}"
685        );
686
687        let missing = render(
688            "<% include 'nope.json' %>",
689            Path::new("/cfg-1108-missing/a.json"),
690            FileAccess::Allowed,
691        )
692        .expect_err("missing include");
693        assert!(matches!(missing, EjsError::Include { .. }));
694        assert!(
695            missing.to_string().starts_with(
696                "EJS include file 'nope.json' not found (/cfg-1108-missing/nope.json): "
697            ),
698            "{missing}"
699        );
700
701        // The fail-closed gate for a fetched document: it must never read a local file.
702        let remote = render(
703            "<% include 'secret.json' %>",
704            Path::new("https://h/i.json"),
705            FileAccess::Denied,
706        )
707        .expect_err("remote include");
708        assert!(matches!(remote, EjsError::LocalFileRefused(_)));
709        assert_eq!(
710            remote.to_string(),
711            "`<% include ... %>` reads a local file and is not honoured in a document fetched from \
712             https://h/i.json — it names 'secret.json'. Only local `--configfile` documents may \
713             include local files; use `--configfile` if the template must, or inline the content \
714             at the source."
715        );
716        let remote_stringify = render(
717            "<%- stringify('secret.js') %>",
718            Path::new("https://h/i.json"),
719            FileAccess::Denied,
720        )
721        .expect_err("remote stringify");
722        assert!(matches!(remote_stringify, EjsError::LocalFileRefused(_)));
723        let message = remote_stringify.to_string();
724        assert!(
725            message.starts_with("`<%- stringify(...) %>` reads a local file")
726                && message.contains("'secret.js'"),
727            "{message}"
728        );
729    }
730
731    #[test]
732    fn has_tags_is_the_opening_delimiter() {
733        assert!(has_tags("a <% b"));
734        assert!(!has_tags("{\"port\": 4545}"));
735        assert!(!has_tags("% > <"));
736    }
737}