use super::Error;
const MAX_DEPTH: usize = 96;
pub(super) fn check(input: &str) -> Result<(), Error> {
let bytes = input.as_bytes();
let mut budget = Budget::default();
let mut mode = Mode::Code;
let mut run = Run::None;
let mut i = 0;
while i < bytes.len() {
match mode {
Mode::Code => {
let (consumed, event) = code_token(bytes, i, run);
run = run.after(bytes[i], &event);
i += consumed;
match event {
Event::Open => budget.open(None)?,
Event::Close => {
if let Some(previous) = budget.close() {
mode = previous;
}
}
Event::Operator => budget.operator()?,
Event::EndOfExpression => budget.end_expression(),
Event::EnterString => mode = Mode::Quoted,
Event::EnterHeredoc { end, tag_len } => {
mode = Mode::Heredoc { end, tag_len };
}
Event::None => {}
}
}
Mode::Heredoc { end, tag_len } if i >= end => {
mode = Mode::Code;
run = Run::None;
i = end + tag_len;
}
Mode::Quoted | Mode::Heredoc { .. } => match literal_token(bytes, i) {
LiteralToken::Interpolation(len) => {
budget.open(Some(mode))?;
mode = Mode::Code;
run = Run::None;
i += len;
}
LiteralToken::Other(len) => {
if matches!(mode, Mode::Quoted) && bytes[i] == b'"' {
mode = Mode::Code;
run = Run::None;
}
i += len;
}
},
}
}
Ok(())
}
#[derive(Default)]
struct Budget {
groups: Vec<Group>,
depth: usize,
}
impl Budget {
fn spend(&mut self) -> Result<(), Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::TooDeep {
depth: self.depth,
max: MAX_DEPTH,
});
}
Ok(())
}
fn open(&mut self, interpolated: Option<Mode>) -> Result<(), Error> {
self.groups.push(Group {
ops: 0,
interpolated,
});
self.spend()
}
fn close(&mut self) -> Option<Mode> {
let group = self.groups.pop()?;
self.depth -= 1 + group.ops;
group.interpolated
}
fn operator(&mut self) -> Result<(), Error> {
if let Some(group) = self.groups.last_mut() {
group.ops += 1;
}
self.spend()
}
fn end_expression(&mut self) {
if let Some(group) = self.groups.last_mut() {
self.depth -= group.ops;
group.ops = 0;
}
}
}
struct Group {
ops: usize,
interpolated: Option<Mode>,
}
#[derive(Debug, Clone, Copy)]
enum Mode {
Code,
Quoted,
Heredoc {
end: usize,
tag_len: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Run {
None,
Ident,
Number,
}
impl Run {
fn after(self, first: u8, event: &Event) -> Self {
match event {
Event::None if is_ident_char(first) => match self {
Self::None if first.is_ascii_digit() => Self::Number,
Self::None => Self::Ident,
run => run,
},
_ => Self::None,
}
}
}
enum Event {
Open,
Close,
Operator,
EndOfExpression,
EnterString,
EnterHeredoc { end: usize, tag_len: usize },
None,
}
fn code_token(bytes: &[u8], at: usize, run: Run) -> (usize, Event) {
let next = bytes.get(at + 1).copied();
match bytes[at] {
b'(' | b'[' | b'{' => (1, Event::Open),
b')' | b']' | b'}' => (1, Event::Close),
b'"' => (1, Event::EnterString),
b'#' => (line_len(bytes, at), Event::None),
b'/' if next == Some(b'/') => (line_len(bytes, at), Event::None),
b'/' if next == Some(b'*') => (block_comment_len(bytes, at), Event::None),
b'=' if next == Some(b'=') => (2, Event::Operator),
b',' | b'=' => (1, Event::EndOfExpression),
b'<' if next == Some(b'<') => match heredoc(bytes, at) {
Some((len, end, tag_len)) => (len, Event::EnterHeredoc { end, tag_len }),
None => (1, Event::Operator),
},
b'!' | b'<' | b'>' if next == Some(b'=') => (2, Event::Operator),
b'&' if next == Some(b'&') => (2, Event::Operator),
b'|' if next == Some(b'|') => (2, Event::Operator),
b'-' if run == Run::Ident && next.is_some_and(is_ident_char) => (1, Event::None),
b'+' | b'-' | b'*' | b'/' | b'%' | b'<' | b'>' | b'?' | b':' => (1, Event::Operator),
_ => (1, Event::None),
}
}
enum LiteralToken {
Interpolation(usize),
Other(usize),
}
fn literal_token(bytes: &[u8], at: usize) -> LiteralToken {
let rest = &bytes[at..];
if rest.starts_with(b"$${") || rest.starts_with(b"%%{") {
return LiteralToken::Other(3);
}
if rest.starts_with(b"${") || rest.starts_with(b"%{") {
let len = if rest.get(2) == Some(&b'~') { 3 } else { 2 };
return LiteralToken::Interpolation(len);
}
if rest.starts_with(b"\\") {
return LiteralToken::Other(2.min(rest.len()));
}
LiteralToken::Other(1)
}
fn heredoc(bytes: &[u8], at: usize) -> Option<(usize, usize, usize)> {
let mut cursor = at + 2;
if bytes.get(cursor) == Some(&b'-') {
cursor += 1;
}
let tag_start = cursor;
if !bytes.get(cursor).copied().is_some_and(is_ident_first) {
return None;
}
while bytes.get(cursor).copied().is_some_and(is_ident_char) {
cursor += 1;
}
let tag = &bytes[tag_start..cursor];
while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\r')) {
cursor += 1;
}
if bytes.get(cursor) != Some(&b'\n') {
return None;
}
let body = cursor + 1;
let mut line = body;
while line <= bytes.len() {
let mut probe = line;
while matches!(bytes.get(probe), Some(b' ' | b'\t')) {
probe += 1;
}
if bytes[probe..].starts_with(tag) {
return Some((body - at, probe, tag.len()));
}
match bytes[line..].iter().position(|b| *b == b'\n') {
Some(offset) => line += offset + 1,
None => break,
}
}
None
}
fn line_len(bytes: &[u8], at: usize) -> usize {
bytes[at..]
.iter()
.position(|b| *b == b'\n')
.unwrap_or(bytes.len() - at)
}
fn block_comment_len(bytes: &[u8], at: usize) -> usize {
bytes[at + 2..]
.windows(2)
.position(|pair| pair == b"*/")
.map_or(bytes.len() - at, |offset| offset + 4)
}
fn is_ident_first(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
#[cfg(test)]
mod tests {
use super::{MAX_DEPTH, check};
use crate::parsers::hcl::Error;
#[track_caller]
fn accepts(input: &str) {
assert!(
check(input).is_ok(),
"rejected an input the parser handles: {input:?}"
);
}
#[track_caller]
fn rejects(input: &str) {
assert!(
matches!(check(input), Err(Error::TooDeep { .. })),
"accepted an input that would overflow the stack: {input:?}"
);
}
#[test]
fn rejects_the_shapes_that_overflow_the_stack() {
let deep = MAX_DEPTH + 1;
rejects(&format!("a = {}1{}\n", "[".repeat(deep), "]".repeat(deep)));
rejects(&format!("a = {}1\n", "[".repeat(deep)));
rejects(&format!("a = 1{}\n", "-1".repeat(deep)));
rejects(&format!("a = 1{}\n", "+1".repeat(deep)));
rejects(&format!(
"a = \"${{{}1{}}}\"\n",
"[".repeat(deep),
"]".repeat(deep)
));
rejects(&format!("{}\n{}", "b {".repeat(deep), "}".repeat(deep)));
}
#[test]
fn literal_text_does_not_spend_the_budget() {
let many = MAX_DEPTH * 4;
accepts(&format!(
"module \"m\" {{\n source = \"gitlab.com/vigigloo/tf-modules/{}\"\n}}\n",
"a-b-".repeat(many)
));
accepts(&format!("# {}\na = 1\n", "[".repeat(many)));
accepts(&format!("// {}\na = 1\n", "-1".repeat(many)));
accepts(&format!("/* {} */\na = 1\n", "{(-".repeat(many)));
accepts(&format!(
"a = <<-EOT\n{}\nEOT\na = 1\n",
"echo --flag --other {{[\n".repeat(many)
));
accepts(&format!("a = b{}\n", "-1".repeat(many)));
}
#[test]
fn interpolations_nest_without_losing_the_enclosing_literal() {
let many = MAX_DEPTH * 4;
accepts(&format!(
"a = <<-EOT\n${{var.x}} {}\n${{var.y}}\nEOT\n",
"--- {{{ [[[ ".repeat(many)
));
rejects(&format!(
"a = <<-EOT\ntext ${{{}1{}}}\nEOT\n",
"[".repeat(MAX_DEPTH + 1),
"]".repeat(MAX_DEPTH + 1)
));
accepts(&format!("a = \"$${{{}}}\"\n", "[".repeat(many)));
}
#[test]
fn an_unterminated_heredoc_is_still_counted() {
let deep = MAX_DEPTH + 1;
rejects(&format!(
"a = <<EOT\n{}1{}\n",
"[".repeat(deep),
"]".repeat(deep)
));
}
#[test]
fn breadth_is_not_depth() {
let many = MAX_DEPTH * 10;
accepts(&format!("a = [{}]\n", "1 + 1, ".repeat(many)));
accepts(&format!("a = {{\n{}}}\n", " k = 1 + 1\n".repeat(many)));
accepts(&"module \"m\" {\n source = \"x\"\n}\n".repeat(many));
}
#[test]
fn the_repository_own_files_stay_well_under_the_limit() {
for path in [
"main.sample.tf",
"tests/sources/parse/main.sample.test.tf",
"tests/sources/parse/subdir/child.sample.test.tf",
"tests/sources/parse/.hidden.sample.test.tf",
] {
let input = std::fs::read_to_string(path).unwrap();
assert!(check(&input).is_ok(), "{path} was rejected");
}
}
}
#[cfg(test)]
mod corpus {
#[test]
#[ignore = "needs HCL_CORPUS"]
fn worst_case_over_a_corpus() {
let list = std::env::var("HCL_CORPUS").expect("HCL_CORPUS");
let paths = std::fs::read_to_string(list).unwrap();
let mut rejected = vec![];
let mut count = 0;
for path in paths.lines().filter(|line| !line.is_empty()) {
let Ok(input) = std::fs::read_to_string(path) else {
continue;
};
count += 1;
if let Err(err) = super::check(&input) {
rejected.push(format!("{path}: {err}"));
}
}
eprintln!("{count} files scanned, {} rejected", rejected.len());
for line in &rejected {
eprintln!(" {line}");
}
assert!(
rejected.is_empty(),
"{} of {count} files rejected",
rejected.len()
);
}
}