use crate::error::{HedlError, HedlResult};
use crate::lex::is_valid_key_token;
use crate::limits::Limits;
#[derive(Debug)]
pub(crate) enum BlockStringResult {
NotBlockString,
MultiLineStarted(BlockStringState),
}
#[derive(Debug)]
pub(crate) struct BlockStringState {
pub key: String,
pub content: Vec<String>,
pub start_line: usize,
pub indent: usize,
pub total_size: usize,
}
impl BlockStringState {
pub fn process_line(
&mut self,
line: &str,
line_num: usize,
limits: &Limits,
) -> HedlResult<Option<String>> {
if let Some(end_pos) = line.find("\"\"\"") {
let before_close = &line[..end_pos];
let new_size = self
.total_size
.checked_add(before_close.len())
.ok_or_else(|| HedlError::security("block string size overflow", line_num))?;
if new_size > limits.max_block_string_size {
return Err(HedlError::security(
format!(
"block string size {} exceeds limit {}",
new_size, limits.max_block_string_size
),
line_num,
));
}
self.total_size = new_size;
self.content.push(before_close.to_string());
let after_close = line[end_pos + 3..].trim();
if !after_close.is_empty() && !after_close.starts_with('#') {
return Err(HedlError::syntax(
"unexpected content after closing \"\"\"",
line_num,
));
}
let full_content = self.content.join("\n");
Ok(Some(full_content))
} else {
let line_contribution = line
.len()
.checked_add(1)
.ok_or_else(|| HedlError::security("line length overflow", line_num))?;
let new_size = self
.total_size
.checked_add(line_contribution)
.ok_or_else(|| HedlError::security("block string size overflow", line_num))?;
if new_size > limits.max_block_string_size {
return Err(HedlError::security(
format!(
"block string size {} exceeds limit {}",
new_size, limits.max_block_string_size
),
line_num,
));
}
self.total_size = new_size;
self.content.push(line.to_string());
Ok(None)
}
}
}
pub(crate) fn try_start_block_string(
content: &str,
indent: usize,
line_num: usize,
) -> HedlResult<BlockStringResult> {
let Some(colon_pos) = content.find(':') else {
return Ok(BlockStringResult::NotBlockString);
};
let key = content[..colon_pos].trim();
let after_colon = &content[colon_pos + 1..];
if !after_colon.is_empty() && !after_colon.starts_with(' ') {
return Ok(BlockStringResult::NotBlockString);
}
let value_str = after_colon.trim();
if !value_str.starts_with("\"\"\"") {
return Ok(BlockStringResult::NotBlockString);
}
if !is_valid_key_token(key) {
return Err(HedlError::syntax(
format!("invalid key: '{}'", key),
line_num,
));
}
let after_open = &value_str[3..];
let after_open_trimmed = after_open.trim_start();
if after_open_trimmed.is_empty() || after_open_trimmed.starts_with('#') {
Ok(BlockStringResult::MultiLineStarted(BlockStringState {
key: key.to_string(),
content: vec![after_open.to_string()],
start_line: line_num,
indent,
total_size: after_open.len(),
}))
} else {
Err(HedlError::syntax(
"block string must have newline after opening \"\"\" (single-line block strings are not allowed)",
line_num,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_not_block_string_no_colon() {
let result = try_start_block_string("just text", 0, 1).unwrap();
assert!(matches!(result, BlockStringResult::NotBlockString));
}
#[test]
fn test_not_block_string_no_triple_quote() {
let result = try_start_block_string("key: value", 0, 1).unwrap();
assert!(matches!(result, BlockStringResult::NotBlockString));
}
#[test]
fn test_not_block_string_double_quote() {
let result = try_start_block_string("key: \"value\"", 0, 1).unwrap();
assert!(matches!(result, BlockStringResult::NotBlockString));
}
#[test]
fn test_not_block_string_no_space_after_colon() {
let result = try_start_block_string("key:\"\"\"", 0, 1).unwrap();
assert!(matches!(result, BlockStringResult::NotBlockString));
}
#[test]
fn test_valid_block_string_start() {
let result = try_start_block_string("description: \"\"\"", 0, 1).unwrap();
match result {
BlockStringResult::MultiLineStarted(state) => {
assert_eq!(state.key, "description");
assert_eq!(state.start_line, 1);
assert_eq!(state.indent, 0);
assert_eq!(state.content.len(), 1);
}
BlockStringResult::NotBlockString => panic!("Expected block string to start"),
}
}
#[test]
fn test_valid_block_string_with_comment() {
let result = try_start_block_string("key: \"\"\" # comment", 0, 1).unwrap();
match result {
BlockStringResult::MultiLineStarted(state) => {
assert_eq!(state.key, "key");
}
BlockStringResult::NotBlockString => panic!("Expected block string to start"),
}
}
#[test]
fn test_invalid_key() {
let result = try_start_block_string("123invalid: \"\"\"", 0, 1);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid key"));
}
#[test]
fn test_content_after_opening_quotes() {
let result = try_start_block_string("key: \"\"\"content", 0, 1);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must have newline after opening"));
}
#[test]
fn test_process_line_accumulation() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 0,
};
let limits = Limits::default();
let result = state.process_line(" Line 1", 2, &limits).unwrap();
assert!(result.is_none()); assert_eq!(state.content.len(), 2);
assert_eq!(state.content[1], " Line 1");
}
#[test]
fn test_process_line_closing() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string(), "Line 1".to_string()],
start_line: 1,
indent: 0,
total_size: 7,
};
let limits = Limits::default();
let result = state.process_line("\"\"\"", 3, &limits).unwrap();
assert!(result.is_some());
let content = result.unwrap();
assert_eq!(content, "\nLine 1\n");
}
#[test]
fn test_process_line_closing_with_content_before() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string(), "Line 1".to_string()],
start_line: 1,
indent: 0,
total_size: 7,
};
let limits = Limits::default();
let result = state.process_line("Line 2\"\"\"", 3, &limits).unwrap();
assert!(result.is_some());
let content = result.unwrap();
assert_eq!(content, "\nLine 1\nLine 2");
}
#[test]
fn test_process_line_closing_with_comment() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string(), "Line 1".to_string()],
start_line: 1,
indent: 0,
total_size: 7,
};
let limits = Limits::default();
let result = state.process_line("\"\"\" # comment", 3, &limits).unwrap();
assert!(result.is_some());
}
#[test]
fn test_process_line_closing_with_invalid_content_after() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 1,
};
let limits = Limits::default();
let result = state.process_line("\"\"\" invalid", 3, &limits);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("unexpected content after closing"));
}
#[test]
fn test_size_limit_exceeded_during_accumulation() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 0,
};
let limits = Limits {
max_block_string_size: 10, ..Default::default()
};
let result = state.process_line(
"This is a very long line that exceeds the limit",
2,
&limits,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("exceeds limit"));
}
#[test]
fn test_size_limit_exceeded_at_closing() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 5,
};
let limits = Limits {
max_block_string_size: 10,
..Default::default()
};
let result = state.process_line("Long content before closing\"\"\"", 2, &limits);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("exceeds limit"));
}
#[test]
fn test_preserve_empty_lines() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string(), "Line 1".to_string()],
start_line: 1,
indent: 0,
total_size: 7,
};
let limits = Limits::default();
state.process_line("", 3, &limits).unwrap();
state.process_line("Line 3", 4, &limits).unwrap();
let result = state.process_line("\"\"\"", 5, &limits).unwrap();
let content = result.unwrap();
assert_eq!(content, "\nLine 1\n\nLine 3\n");
}
#[test]
fn test_preserve_indentation() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 1,
};
let limits = Limits::default();
state.process_line(" indented", 2, &limits).unwrap();
state.process_line(" more indented", 3, &limits).unwrap();
let result = state.process_line("\"\"\"", 4, &limits).unwrap();
let content = result.unwrap();
assert_eq!(content, "\n indented\n more indented\n");
}
#[test]
fn test_block_string_line_overflow() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 0,
};
let limits = Limits {
max_block_string_size: usize::MAX,
..Default::default()
};
state.total_size = usize::MAX - 10;
let result = state.process_line("x".repeat(100).as_str(), 1, &limits);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("overflow"),
"Expected overflow error, got: {}",
err_msg
);
}
#[test]
fn test_block_string_cumulative_overflow() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: usize::MAX - 10,
};
let limits = Limits {
max_block_string_size: usize::MAX,
..Default::default()
};
let result = state.process_line("x".repeat(100).as_str(), 1, &limits);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("overflow"),
"Expected overflow error, got: {}",
err_msg
);
}
#[test]
fn test_block_string_normal_operation_unaffected() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 0,
};
let limits = Limits {
max_block_string_size: 1000,
..Default::default()
};
assert!(state.process_line("normal line", 1, &limits).is_ok());
assert!(state.process_line("another line", 2, &limits).is_ok());
assert!(state.process_line("third line", 3, &limits).is_ok());
let result = state.process_line("\"\"\"", 4, &limits);
assert!(result.is_ok());
assert!(result.unwrap().is_some());
}
#[test]
fn test_block_string_overflow_at_exactly_max() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 0,
};
let limits = Limits {
max_block_string_size: usize::MAX,
..Default::default()
};
state.total_size = usize::MAX - 5;
let result = state.process_line("123456", 1, &limits);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("overflow"),
"Expected overflow error, got: {}",
err_msg
);
}
#[test]
fn test_block_string_no_overflow_just_under_limit() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: 90,
};
let limits = Limits {
max_block_string_size: 100,
..Default::default()
};
let result = state.process_line("12345678", 1, &limits);
assert!(result.is_ok());
assert_eq!(state.total_size, 99);
}
#[test]
fn test_block_string_overflow_security_message() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: usize::MAX - 1,
};
let limits = Limits {
max_block_string_size: usize::MAX,
..Default::default()
};
let result = state.process_line("test", 42, &limits);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("overflow"),
"Expected overflow in error message"
);
assert!(
err_msg.contains("line length overflow")
|| err_msg.contains("block string size overflow")
);
}
#[test]
fn test_block_string_closing_overflow() {
let mut state = BlockStringState {
key: "test".to_string(),
content: vec!["".to_string()],
start_line: 1,
indent: 0,
total_size: usize::MAX - 5,
};
let limits = Limits {
max_block_string_size: usize::MAX,
..Default::default()
};
let result = state.process_line("long content before\"\"\"", 2, &limits);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("overflow"),
"Expected overflow error, got: {}",
err_msg
);
}
}