use anyhow::{Result, bail};
pub fn check(text: &str) -> Result<()> {
check_no_reserved_markers(text)?;
check_single_document(text)?;
Ok(())
}
fn check_single_document(text: &str) -> Result<()> {
let document_count = serde_yaml::Deserializer::from_str(text).count();
if document_count > 1 {
bail!(
"robot.yaml must be a single YAML document; found {document_count} \
(multi-document `---` streams are rejected by strict parsing)"
);
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Quote {
None,
Single,
Double,
}
fn starts_token(prev_non_space: Option<char>) -> bool {
matches!(
prev_non_space,
None | Some(':') | Some('-') | Some('[') | Some('{') | Some(',') | Some('?')
)
}
fn check_no_reserved_markers(text: &str) -> Result<()> {
let mut quote = Quote::None;
let mut block_scalar_parent_indent: Option<usize> = None;
for (line_index, raw_line) in text.split_inclusive('\n').enumerate() {
let line_no = line_index + 1;
let line = raw_line.trim_end_matches(['\r', '\n']);
if let Some(parent_indent) = block_scalar_parent_indent {
if line.trim().is_empty() {
continue;
}
if line_indent(line) > parent_indent {
continue;
}
block_scalar_parent_indent = None;
}
if let Some(parent_indent) = check_line_no_reserved_markers(line, line_no, &mut quote)? {
block_scalar_parent_indent = Some(parent_indent);
}
}
Ok(())
}
fn check_line_no_reserved_markers(
line: &str,
line_no: usize,
quote: &mut Quote,
) -> Result<Option<usize>> {
let mut in_comment = false;
let mut prev_char: Option<char> = None;
let mut prev_non_space: Option<char> = None;
let mut chars = line.chars().enumerate().peekable();
while let Some((index, ch)) = chars.next() {
let col_no = index + 1;
if in_comment {
continue;
}
match *quote {
Quote::Single => {
if ch == '\'' {
if chars.peek().is_some_and(|(_, next)| *next == '\'') {
chars.next();
} else {
*quote = Quote::None;
}
}
prev_char = Some(ch);
prev_non_space = Some(ch);
continue;
}
Quote::Double => {
if ch == '\\' {
chars.next();
} else if ch == '"' {
*quote = Quote::None;
}
prev_char = Some(ch);
prev_non_space = Some(ch);
continue;
}
Quote::None => {}
}
match ch {
'\'' => {
*quote = Quote::Single;
prev_char = Some(ch);
prev_non_space = Some(ch);
}
'"' => {
*quote = Quote::Double;
prev_char = Some(ch);
prev_non_space = Some(ch);
}
'#' if matches!(prev_char, None | Some(' ') | Some('\t')) => {
in_comment = true;
}
'|' | '>' if starts_token(prev_non_space) && is_block_scalar_header_tail(&chars) => {
return Ok(Some(line_indent(line)));
}
'&' if starts_token(prev_non_space) => {
bail!(
"robot.yaml:{line_no}:{col_no}: YAML anchors ('&name') are rejected by \
strict parsing"
);
}
'*' if starts_token(prev_non_space) => {
bail!(
"robot.yaml:{line_no}:{col_no}: YAML aliases ('*name') are rejected by \
strict parsing"
);
}
'!' if starts_token(prev_non_space) => {
bail!(
"robot.yaml:{line_no}:{col_no}: explicit YAML tags ('!...') are rejected by \
strict parsing"
);
}
'<' if starts_token(prev_non_space)
&& chars.peek().is_some_and(|(_, next)| *next == '<') =>
{
bail!(
"robot.yaml:{line_no}:{col_no}: YAML merge keys ('<<') are rejected by \
strict parsing"
);
}
' ' | '\t' => {
prev_char = Some(ch);
continue;
}
_ => {
prev_char = Some(ch);
prev_non_space = Some(ch);
}
}
}
Ok(None)
}
fn line_indent(line: &str) -> usize {
line.chars()
.take_while(|ch| matches!(ch, ' ' | '\t'))
.count()
}
fn is_block_scalar_header_tail<I>(chars: &std::iter::Peekable<I>) -> bool
where
I: Iterator<Item = (usize, char)> + Clone,
{
let mut seen_chomping = false;
let mut seen_indent = false;
let mut tail = chars.clone();
while let Some((_, ch)) = tail.peek().copied() {
match ch {
'+' | '-' if !seen_chomping => {
seen_chomping = true;
tail.next();
}
'0'..='9' if !seen_indent => {
seen_indent = true;
tail.next();
}
' ' | '\t' => break,
'#' => break,
_ => return false,
}
}
for (_, ch) in tail {
match ch {
' ' | '\t' => {}
'#' => return true,
_ => return false,
}
}
true
}
#[cfg(test)]
mod tests {
use super::check;
#[test]
fn accepts_plain_manifest_text() {
check("schema: robot/v0\nrobot:\n id: rover\n namespace: dev\n")
.expect("plain text is fine");
}
#[test]
fn accepts_quoted_strings_containing_marker_characters() {
check("a: \"AT&T corp\"\nb: 'star * here'\nc: \"tag !like this\"\n")
.expect("marker characters inside quotes are not markers");
}
#[test]
fn accepts_comments_containing_marker_characters() {
check("a: 1 # trailing & * ! << comment\nb: 2\n").expect("comment text is not scanned");
}
#[test]
fn accepts_block_scalars_containing_marker_characters() {
check(
"description: |\n literal & anchor-looking text\n alias *x text\n tag !Thing text\n merge << text\nnext: 1\n",
)
.expect("block scalar contents are plain text, not YAML markers");
}
#[test]
fn rejects_anchor() {
let error = check("a: &x 1\nb: 2\n").expect_err("anchor should be rejected");
assert!(format!("{error:#}").contains("anchor"), "got: {error:#}");
}
#[test]
fn rejects_alias() {
let error = check("a: *x\n").expect_err("alias should be rejected");
assert!(format!("{error:#}").contains("alias"), "got: {error:#}");
}
#[test]
fn rejects_merge_key() {
let error =
check("foo:\n <<: {x: 1}\n y: 2\n").expect_err("merge key should be rejected");
assert!(format!("{error:#}").contains("merge"), "got: {error:#}");
}
#[test]
fn rejects_flow_style_reserved_markers() {
let error = check("foo: {<<: {x: 1}}\n").expect_err("flow merge key should be rejected");
assert!(format!("{error:#}").contains("merge"), "got: {error:#}");
let error = check("foo: {&name x: 1}\n").expect_err("flow anchor should be rejected");
assert!(format!("{error:#}").contains("anchor"), "got: {error:#}");
}
#[test]
fn rejects_explicit_tag() {
let error = check("a: !!str 123\n").expect_err("explicit tag should be rejected");
assert!(format!("{error:#}").contains("tag"), "got: {error:#}");
}
#[test]
fn rejects_multi_document_stream() {
let error =
check("a: 1\n---\nb: 2\n").expect_err("multi-document stream should be rejected");
assert!(
format!("{error:#}").contains("single YAML document"),
"got: {error:#}"
);
}
}