1use regex::Regex;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11static 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
22static 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
28static EJS_EXPR_RE: LazyLock<Regex> = LazyLock::new(|| {
30 Regex::new(r"<%=\s*(.*?)\s*%>").expect("EJS expression pattern is a valid constant regex")
31});
32
33static 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum FileAccess {
50 Allowed,
52 Denied,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Rendered {
59 pub text: String,
60 pub unset_env: Vec<UnsetEnv>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct UnsetEnv {
68 pub name: String,
69 pub place: String,
71 pub reason: EnvProblem,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum EnvProblem {
77 Unset,
79 NotUnicode,
81}
82
83impl UnsetEnv {
84 #[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#[derive(Debug, thiserror::Error)]
104pub enum EjsError {
105 #[error("{0}")]
107 UnsupportedTag(String),
108 #[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#[must_use]
132pub fn has_tags(content: &str) -> bool {
133 content.contains("<%")
134}
135
136pub 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 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#[derive(Debug)]
209struct ExpandedDocument {
210 text: String,
211 includes: Vec<IncludedSpan>,
212}
213
214#[derive(Debug)]
216struct IncludedSpan {
217 range: std::ops::Range<usize>,
219 tag_offset: usize,
221 tag_len: usize,
222 file: String,
224}
225
226impl ExpandedDocument {
227 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292enum TagScope {
293 Document,
295 Stringified,
298}
299
300fn 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
370fn 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 Ok(json_quoted[1..json_quoted.len() - 1].to_string())
402}
403
404fn 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
414struct EnvExpression<'t> {
416 value: String,
417 problem: Option<(&'t str, EnvProblem)>,
419}
420
421fn 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#[derive(Debug)]
450struct UnsupportedTag<'a> {
451 text: &'a str,
453 terminated: bool,
454 note: &'static str,
456}
457
458impl UnsupportedTag<'_> {
459 const MAX_SHOWN_CHARS: usize = 80;
460
461 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 #[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 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 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}