use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Parser;
use serde::de::IgnoredAny;
use crate::hook::Hook;
use crate::hooks::HookOutput;
use crate::hooks::pre_commit_hooks::{parse_hook_args, run_blocking_file_checks};
#[derive(Parser)]
#[command(disable_help_subcommand = true)]
#[command(disable_version_flag = true)]
#[command(disable_help_flag = true)]
pub(crate) struct Args {
#[arg(long, short = 'm', visible_alias = "multi")]
allow_multiple_documents: bool,
#[arg(long, conflicts_with = "unsafe")]
disallow_unknown_tags: bool,
#[arg(long)]
r#unsafe: bool,
#[arg(value_name = "FILENAMES")]
filenames: Vec<PathBuf>,
}
#[derive(Clone, Copy)]
enum CheckMode {
Load {
multiple: bool,
disallow_unknown_tags: bool,
},
SyntaxOnly,
}
pub(crate) async fn run(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
let args: Args = parse_hook_args(hook)?;
let mode = if args.r#unsafe {
CheckMode::SyntaxOnly
} else {
CheckMode::Load {
multiple: args.allow_multiple_documents,
disallow_unknown_tags: args.disallow_unknown_tags,
}
};
run_blocking_file_checks(
hook.project().relative_path(),
&args.filenames,
filenames,
move |file_path, filename| check_file(file_path, filename, mode),
)
.await
}
fn check_file(file_path: &Path, filename: &Path, mode: CheckMode) -> Result<HookOutput> {
let content = fs_err::read(file_path)?;
if content.is_empty() {
return Ok(HookOutput::unchanged(0, Vec::new()));
}
let output = match mode {
CheckMode::Load {
multiple,
disallow_unknown_tags,
} => check_loaded(filename, &content, multiple, disallow_unknown_tags),
CheckMode::SyntaxOnly => check_syntax(filename, &content),
};
Ok(output)
}
fn check_loaded(
filename: &Path,
content: &[u8],
allow_multi_docs: bool,
disallow_unknown_tags: bool,
) -> HookOutput {
let options = serde_saphyr::options! {
budget: serde_saphyr::budget! {
enforce_alias_anchor_ratio: false,
},
emit_comments: false,
ignore_binary_tag_for_string: true,
reject_unsupported_tags: disallow_unknown_tags,
reject_non_finite_typeless_float: false,
};
let result = match simdutf8::compat::from_utf8(content) {
Ok(content) if allow_multi_docs => {
serde_saphyr::from_multiple_with_options::<IgnoredAny>(content, options).map(|_| ())
}
Ok(content) => {
serde_saphyr::from_str_with_options::<IgnoredAny>(content, options).map(|_| ())
}
Err(_) => Err(serde_saphyr::Error::InvalidUtf8Input),
};
match result {
Ok(()) => HookOutput::unchanged(0, Vec::new()),
Err(e) => {
let err = e.render_with_options(serde_saphyr::render_options! {
snippets: serde_saphyr::SnippetMode::Off,
formatter: &serde_saphyr::UserMessageFormatter,
});
let error_message = format!("{}: Failed to yaml decode ({err})\n", filename.display());
HookOutput::unchanged(1, error_message.into_bytes())
}
}
}
fn check_syntax(filename: &Path, content: &[u8]) -> HookOutput {
let content = match simdutf8::compat::from_utf8(content) {
Ok(content) => content,
Err(error) => {
let error_message =
format!("{}: Failed to decode UTF-8 ({error})\n", filename.display());
return HookOutput::unchanged(1, error_message.into_bytes());
}
};
let options = granit_parser::options! {
emit_comments: false,
};
for event in granit_parser::Parser::with_options(granit_parser::StrInput::new(content), options)
{
if let Err(error) = event {
let error_message = format!("{}: Failed to yaml parse ({error})\n", filename.display());
return HookOutput::unchanged(1, error_message.into_bytes());
}
}
HookOutput::unchanged(0, Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::fmt::Write;
use std::path::PathBuf;
use tempfile::tempdir;
const LOAD_SINGLE_DOCUMENT: CheckMode = CheckMode::Load {
multiple: false,
disallow_unknown_tags: false,
};
const LOAD_MULTIPLE_DOCUMENTS: CheckMode = CheckMode::Load {
multiple: true,
disallow_unknown_tags: false,
};
async fn create_test_file(
dir: &tempfile::TempDir,
name: &str,
content: &[u8],
) -> Result<PathBuf> {
let file_path = dir.path().join(name);
fs_err::tokio::write(&file_path, content).await?;
Ok(file_path)
}
#[test]
fn test_unsafe_argument() -> Result<()> {
let args = Args::try_parse_from(["check-yaml", "--unsafe", "config.yaml"])?;
assert!(args.r#unsafe);
assert_eq!(args.filenames, [PathBuf::from("config.yaml")]);
Ok(())
}
#[tokio::test]
async fn test_valid_yaml() -> Result<()> {
let dir = tempdir()?;
let content = br"key1: value1
key2: value2
";
let file_path = create_test_file(&dir, "valid.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 0);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_yaml_with_non_finite_floats() -> Result<()> {
let dir = tempdir()?;
let content = br"nan: .nan
positive_infinity: .inf
negative_infinity: -.inf
";
let file_path = create_test_file(&dir, "non-finite.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(
result.exit_status,
0,
"{}",
String::from_utf8_lossy(&result.output)
);
assert!(result.output.is_empty());
Ok(())
}
#[test]
fn test_under_indented_flow_sequence() {
let filename = Path::new("targets.yaml");
let content = b"- targets: [\n \"192.168.1.1:9100\",\n \"192.168.1.2:9100\",\n]\n";
for result in [
check_loaded(filename, content, false, false),
check_syntax(filename, content),
] {
assert_eq!((result.exit_status, result.output), (0, Vec::new()));
}
}
#[tokio::test]
async fn test_invalid_yaml() -> Result<()> {
let dir = tempdir()?;
let content = br"key1: value1
key2: value2: another_value
";
let file_path = create_test_file(&dir, "invalid.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 1);
assert!(!result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_duplicate_keys() -> Result<()> {
let dir = tempdir()?;
let content = br"key1: value1
key1: value2
";
let file_path = create_test_file(&dir, "duplicate.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 1);
assert!(!result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_empty_yaml() -> Result<()> {
let dir = tempdir()?;
let content = b"";
let file_path = create_test_file(&dir, "empty.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 0);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_multiple_documents() -> Result<()> {
let dir = tempdir()?;
let content = b"\
---
key1: value1
---
key2: value2
";
let file_path = create_test_file(&dir, "multi.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 1);
assert!(!result.output.is_empty());
let result = check_file(&file_path, &file_path, LOAD_MULTIPLE_DOCUMENTS)?;
assert_eq!(result.exit_status, 0);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_unsafe_allows_multiple_documents() -> Result<()> {
let dir = tempdir()?;
let content = b"---\nkey1: value1\n---\nkey2: value2\n";
let file_path = create_test_file(&dir, "multi.yaml", content).await?;
let result = check_file(&file_path, &file_path, CheckMode::SyntaxOnly)?;
assert_eq!(
result.exit_status,
0,
"{}",
String::from_utf8_lossy(&result.output)
);
Ok(())
}
#[tokio::test]
async fn test_unsafe_checks_later_documents() -> Result<()> {
let dir = tempdir()?;
let content = b"---\nkey: value\n---\n[";
let file_path = create_test_file(&dir, "invalid-multi.yaml", content).await?;
let result = check_file(&file_path, &file_path, CheckMode::SyntaxOnly)?;
assert_eq!(result.exit_status, 1);
assert!(!result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_unsafe_allows_values_rejected_while_loading() -> Result<()> {
let dir = tempdir()?;
let content = b"duplicate: first\nduplicate: second\ntarget:\n <<: not-a-map\n";
let file_path = create_test_file(&dir, "unsafe.yaml", content).await?;
let result = check_file(&file_path, &file_path, CheckMode::SyntaxOnly)?;
assert_eq!(
result.exit_status,
0,
"{}",
String::from_utf8_lossy(&result.output)
);
Ok(())
}
#[tokio::test]
async fn test_unsafe_rejects_invalid_syntax() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "invalid.yaml", b"[").await?;
let result = check_file(&file_path, &file_path, CheckMode::SyntaxOnly)?;
assert_eq!(result.exit_status, 1);
assert!(!result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_unsafe_rejects_invalid_utf8() -> Result<()> {
let dir = tempdir()?;
let file_path = create_test_file(&dir, "invalid.yaml", b"key: \xff").await?;
let result = check_file(&file_path, &file_path, CheckMode::SyntaxOnly)?;
assert_eq!(result.exit_status, 1);
assert!(String::from_utf8_lossy(&result.output).contains("Failed to decode UTF-8"));
Ok(())
}
#[test]
fn test_unknown_yaml_tag_is_allowed_by_default() {
let filename = Path::new("tagged.yaml");
let content = b"foo: !reference [.bar, script]\n";
let result = check_loaded(filename, content, false, false);
assert_eq!((result.exit_status, result.output), (0, Vec::new()));
}
#[test]
fn test_disallow_unknown_tags_rejects_unknown_yaml_tag() {
let filename = Path::new("tagged.yaml");
let content = b"foo: !reference [.bar, script]\n";
let result = check_loaded(filename, content, false, true);
assert_eq!(result.exit_status, 1);
insta::assert_snapshot!(String::from_utf8_lossy(&result.output), @"tagged.yaml: Failed to yaml decode (unsupported tag `!reference` at line 1, column 17)");
}
#[test]
fn test_unsafe_allows_unknown_yaml_tag() {
let filename = Path::new("tagged.yaml");
let content = b"foo: !reference [.bar, script]\n";
let result = check_syntax(filename, content);
assert_eq!((result.exit_status, result.output), (0, Vec::new()));
}
#[tokio::test]
async fn test_yaml_with_binary_scalar() -> Result<()> {
let dir = tempdir()?;
let content = b"\
response:
body:
string: !!binary |
H4sIAAAAAAAAA4xTPW/bMBDd9SsON9uFJaeJ4y0oujRIEXQpisiQaOokM6VIgjzFSQ3/94KSYzmt
A2TRwPfBd/eoXQKAqsIloNwIlq3T0y/rF6JfbXYT2m3rvan+NLfXt/zj2/f5NsVJVNj1I0l+VX2S
tnWaWFkzwNKTYIqu6dXlPL28mmeLHmhtRTrKGsfTCzvNZtnFNE2n2ewg3FglKeASHhIAgF3/jRFN
Rc+4hNnk9aSlEERDuDySANBbHU9QhKACC8M4GUFpDZPpU5dl+Risyc0uNwA5smJNOS4hxxu4Jx8c
SVZPBNbA12enhRFxugC2hjurSXZaeLj3VCkZAbiLg4UcJ4Of6HhjfYiODzn+JK3FVjATEIPQOa4O
vMqqyDGd1rnZ56Ysy9PEnuouCH1gnADCGMtDpHjF6oDsj9vRtnHersM/UqyVUWFTeBLBmriJwNZh
j+4TgFXfQvdmsei8bR0XbH9Tf91iPtjhWPsIzq8PIFsWejxPs2xyxq6oiIXS4aRGlEJuqBqlY+ei
q5Q9AZKTof9Pc857GFyZ5iP2IyAlOaaqcMfGz9E8xb/iPdpxyX1gDOSflKSCFflYREW16PTwYDG8
BKa2qJVpyDuvhldbu0LOFtnicypnC0z2yV8AAAD//wMALvIkjL4DAAA=
";
let file_path = create_test_file(&dir, "binary.yaml", content).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(result.exit_status, 0);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_yaml_with_many_aliases_and_few_anchors() -> Result<()> {
let dir = tempdir()?;
let mut content = indoc::formatdoc! {"
defaults: &defaults
image: alpine
services:
"};
for index in 0..158 {
let _ = write!(content, " svc{index}:\n <<: *defaults\n");
}
let file_path = create_test_file(&dir, "many-aliases.yaml", content.as_bytes()).await?;
let result = check_file(&file_path, &file_path, LOAD_SINGLE_DOCUMENT)?;
assert_eq!(
result.exit_status,
0,
"{}",
String::from_utf8_lossy(&result.output)
);
assert!(result.output.is_empty());
Ok(())
}
#[tokio::test]
async fn test_yaml_with_many_consecutive_comments() -> Result<()> {
let dir = tempdir()?;
let mut content = "items:\n".to_string();
for index in 0..100 {
let _ = writeln!(content, " # comment {index}");
}
content.push_str(" - value\n");
let file_path = create_test_file(&dir, "many-comments.yaml", content.as_bytes()).await?;
for mode in [LOAD_SINGLE_DOCUMENT, CheckMode::SyntaxOnly] {
let result = check_file(&file_path, &file_path, mode)?;
assert_eq!(
result.exit_status,
0,
"{}",
String::from_utf8_lossy(&result.output)
);
assert!(result.output.is_empty());
}
Ok(())
}
}