pub fn validate_c_header_filename(value: &str) -> Result<(), String> {
let Some(stem) = value.strip_suffix(".h") else {
return Err(format!("`{value}` must end in `.h`"));
};
if stem.is_empty() {
return Err("must have a non-empty name before `.h`".to_string());
}
if value.contains('/') || value.contains('\\') {
return Err(format!(
"`{value}` must be a single filename with no path separators (`/` or `\\`)"
));
}
let allowed = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.';
if !value.chars().all(allowed) {
return Err(format!(
"`{value}` may only contain ASCII letters, digits, `_`, `-`, and `.`"
));
}
Ok(())
}
pub fn validate_native_artifact_basename(value: &str) -> Result<(), String> {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err("must not be empty".to_string());
};
if !(first.is_ascii_alphanumeric() || first == '_') {
return Err(format!("`{value}` must start with an ASCII letter, digit, or `_`"));
}
let rest_allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+');
if !chars.all(rest_allowed) {
return Err(format!(
"`{value}` may only contain ASCII letters, digits, `_`, `-`, `.`, and `+` after the first character"
));
}
Ok(())
}
pub fn validate_c_make_path(value: &str, output_base: &str) -> Result<(), String> {
if value.is_empty() || std::path::Path::new(value).is_absolute() || value.contains('\\') {
return Err(format!("`{value}` must be a non-empty relative POSIX path"));
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
{
return Err(format!("`{value}` contains a character active in Make or the shell"));
}
let mut depth = lexical_relative_depth(output_base)? + 1;
for component in value.split('/') {
match component {
"" | "." => {}
".." if depth == 0 => return Err(format!("`{value}` escapes the repository root")),
".." => depth -= 1,
_ => depth += 1,
}
}
Ok(())
}
pub fn validate_c_output_base(value: &str) -> Result<(), String> {
lexical_relative_depth(value).map(|_| ())
}
fn lexical_relative_depth(value: &str) -> Result<usize, String> {
if value.starts_with('/') || value.contains('\\') || has_windows_drive_prefix(value) {
return Err(format!(
"output base `{value}` must be a repository-relative POSIX path"
));
}
let mut depth = 0;
for component in value.split('/') {
match component {
"" | "." => {}
".." if depth == 0 => return Err(format!("output base `{value}` escapes the repository root")),
".." => depth -= 1,
_ => depth += 1,
}
}
Ok(depth)
}
fn has_windows_drive_prefix(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
pub fn relative_repo_path(from_directory: &str, target: &str) -> Result<String, String> {
let from = normalized_relative_components(from_directory)?;
let to = normalized_relative_components(target)?;
let shared = from.iter().zip(&to).take_while(|(left, right)| left == right).count();
let mut parts = vec![".."; from.len() - shared];
parts.extend(to[shared..].iter().map(String::as_str));
Ok(if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
})
}
fn normalized_relative_components(value: &str) -> Result<Vec<String>, String> {
lexical_relative_depth(value)?;
let mut parts = Vec::new();
for component in value.split('/') {
match component {
"" | "." => {}
".." => {
parts.pop();
}
normal => parts.push(normal.to_string()),
}
}
Ok(parts)
}
pub fn validate_ascii_abi_identifier(value: &str) -> Result<(), String> {
validate_ascii_abi_prefix(value)?;
if is_c_reserved_keyword(value) {
return Err(format!("`{value}` is a reserved C keyword"));
}
Ok(())
}
pub fn validate_ascii_abi_prefix(value: &str) -> Result<(), String> {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err("must not be empty".to_string());
};
if !first.is_ascii_alphabetic() {
return Err(format!("`{value}` must start with an ASCII letter"));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"`{value}` may only contain ASCII letters, digits, and `_` after the first character"
));
}
Ok(())
}
const C_RESERVED_KEYWORDS: &[&str] = &[
"auto",
"break",
"case",
"char",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extern",
"float",
"for",
"goto",
"if",
"inline",
"int",
"long",
"register",
"restrict",
"return",
"short",
"signed",
"sizeof",
"static",
"struct",
"switch",
"typedef",
"union",
"unsigned",
"void",
"volatile",
"while",
"_Alignas",
"_Alignof",
"_Atomic",
"_Bool",
"_Complex",
"_Decimal32",
"_Decimal64",
"_Decimal128",
"_Generic",
"_Imaginary",
"_Noreturn",
"_Static_assert",
"_Thread_local",
"alignas",
"alignof",
"bool",
"constexpr",
"false",
"nullptr",
"static_assert",
"thread_local",
"true",
"typeof",
"typeof_unqual",
];
fn is_c_reserved_keyword(value: &str) -> bool {
C_RESERVED_KEYWORDS.contains(&value)
}
pub fn validate_rust_pointee_type_path(value: &str) -> Result<(), String> {
let parsed: syn::TypePath =
syn::parse_str(value).map_err(|error| format!("`{value}` is not a Rust type path: {error}"))?;
if parsed.qself.is_some() {
return Err(format!(
"`{value}` must not use qualified-path syntax (`<Type as Trait>::...`)"
));
}
for segment in &parsed.path.segments {
if !matches!(segment.arguments, syn::PathArguments::None) {
return Err(format!(
"`{value}` must not carry generic arguments (segment `{}` has some)",
segment.ident
));
}
}
Ok(())
}
pub fn validate_cfg_expression(value: &str) -> Result<(), String> {
if value.chars().any(|ch| ch == '\'' || ch.is_control()) {
return Err("cfg expression contains a character that is active in its TOML literal-string host".to_string());
}
let expr: syn::Expr =
syn::parse_str(value).map_err(|error| format!("`{value}` is not a valid cfg expression: {error}"))?;
validate_cfg_expr_shape(&expr)
}
fn validate_cfg_expr_shape(expr: &syn::Expr) -> Result<(), String> {
match expr {
syn::Expr::Path(p) if p.qself.is_none() => validate_cfg_bare_path(&p.path),
syn::Expr::Assign(assign) => {
let syn::Expr::Path(lhs) = assign.left.as_ref() else {
return Err("cfg predicate key must be a bare identifier".to_string());
};
validate_cfg_bare_path(&lhs.path)?;
match assign.right.as_ref() {
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(_), ..
}) => Ok(()),
_ => Err("cfg predicate value must be a double-quoted string literal".to_string()),
}
}
syn::Expr::Call(call) => {
let syn::Expr::Path(func) = call.func.as_ref() else {
return Err("cfg combinator must be a bare `any`/`all`/`not`".to_string());
};
let Some(name) = func.path.get_ident().map(ToString::to_string) else {
return Err("cfg combinator must be a single bare identifier".to_string());
};
if !matches!(name.as_str(), "any" | "all" | "not") {
return Err(format!("`{name}` is not a valid cfg combinator (expected any/all/not)"));
}
if name == "not" && call.args.len() != 1 {
return Err("cfg combinator `not` requires exactly one argument".to_string());
}
for arg in &call.args {
validate_cfg_expr_shape(arg)?;
}
Ok(())
}
_ => Err(
"unsupported cfg expression shape — expected a bare key, `key = \"value\"`, or any/all/not(...)"
.to_string(),
),
}
}
fn validate_cfg_bare_path(path: &syn::Path) -> Result<(), String> {
let Some(ident) = path.get_ident() else {
return Err("cfg key must be a single bare identifier, not a `::`-separated path".to_string());
};
let text = ident.to_string();
if text.starts_with("r#") {
return Err(format!("`{text}` must not be a raw identifier"));
}
validate_cfg_identifier(&text)
}
fn validate_cfg_identifier(value: &str) -> Result<(), String> {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err("cfg key must not be empty".to_string());
};
if !(first.is_ascii_alphabetic() || first == '_')
|| !chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
{
return Err(format!("`{value}` is not a portable cfg identifier"));
}
Ok(())
}
pub fn validate_cargo_package_name(value: &str) -> Result<(), String> {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err("must not be empty".to_string());
};
if !first.is_ascii_alphabetic() {
return Err(format!("`{value}` must start with an ASCII letter"));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(format!(
"`{value}` may only contain ASCII letters, digits, `_`, and `-` after the first character"
));
}
Ok(())
}
pub fn validate_cargo_version_req(value: &str) -> Result<(), String> {
semver::VersionReq::parse(value)
.map(|_| ())
.map_err(|error| format!("`{value}` is not a valid Cargo version requirement: {error}"))
}
pub fn validate_cargo_feature_name(value: &str) -> Result<(), String> {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err("must not be empty".to_string());
};
if !(first.is_ascii_alphanumeric() || first == '_') {
return Err(format!("`{value}` must start with an ASCII letter, digit, or `_`"));
}
let rest_allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.');
if !chars.all(rest_allowed) {
return Err(format!(
"`{value}` may only contain ASCII letters, digits, `_`, `-`, and `.` after the first character"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_filename_accepts_default_shape() {
assert_eq!(validate_c_header_filename("my_lib.h"), Ok(()));
assert_eq!(validate_c_header_filename("custom.h"), Ok(()));
}
#[test]
fn header_filename_rejects_path_traversal() {
assert!(validate_c_header_filename("../../etc/passwd.h").is_err());
assert!(validate_c_header_filename("sub/dir.h").is_err());
assert!(validate_c_header_filename("dir\\file.h").is_err());
}
#[test]
fn header_filename_rejects_rust_string_breakout() {
assert!(validate_c_header_filename("evil\".h").is_err());
assert!(validate_c_header_filename("evil\nfn x(){}.h").is_err());
}
#[test]
fn header_filename_requires_h_suffix() {
assert!(validate_c_header_filename("my_lib").is_err());
assert!(validate_c_header_filename(".h").is_err());
}
#[test]
fn artifact_basename_accepts_default_shape() {
assert_eq!(validate_native_artifact_basename("my_lib_ffi"), Ok(()));
assert_eq!(validate_native_artifact_basename("libmy_custom"), Ok(()));
assert_eq!(validate_native_artifact_basename("gtk+-3.0"), Ok(()));
}
#[test]
fn artifact_basename_rejects_make_shell_canary() {
assert!(validate_native_artifact_basename("$(shell rm -rf /)").is_err());
assert!(validate_native_artifact_basename("foo)\nevil:\n\trm -rf /").is_err());
}
#[test]
fn artifact_basename_rejects_whitespace_and_slash() {
assert!(validate_native_artifact_basename("my lib").is_err());
assert!(validate_native_artifact_basename("my/lib").is_err());
}
#[test]
fn c_make_path_rejects_expansion_and_repository_escape() {
assert_eq!(validate_c_make_path("../../crates/sample-ffi", "e2e"), Ok(()));
assert!(validate_c_make_path("../../$(shell touch pwned)", "e2e").is_err());
assert!(validate_c_make_path("../../../outside", "e2e").is_err());
assert!(validate_c_make_path("../../../outside", "e2e/nested/../..").is_err());
assert!(validate_c_make_path("../crates/sample-ffi", "/tmp/e2e").is_err());
assert!(validate_c_make_path("../crates/sample-ffi", r"C:\tmp\e2e").is_err());
assert!(validate_c_make_path("../crates/sample-ffi", "../outside/e2e").is_err());
}
#[test]
fn c_output_base_rejects_windows_drive_absolute_path() {
assert!(validate_c_output_base("C:/tmp/e2e").is_err());
assert!(validate_c_output_base("C:tmp/e2e").is_err());
}
#[test]
fn abi_identifier_accepts_default_shape() {
assert_eq!(validate_ascii_abi_identifier("my_lib"), Ok(()));
assert_eq!(validate_ascii_abi_identifier("TSLanguage"), Ok(()));
}
#[test]
fn abi_prefix_accepts_keyword_when_used_as_namespace() {
assert_eq!(validate_ascii_abi_prefix("int"), Ok(()));
assert!(validate_ascii_abi_identifier("int").is_err());
}
#[test]
fn abi_identifier_rejects_quote_breakout() {
assert!(validate_ascii_abi_identifier("evil\", \"x").is_err());
assert!(validate_ascii_abi_identifier("evil-prefix").is_err());
assert!(validate_ascii_abi_identifier("123start").is_err());
assert!(validate_ascii_abi_identifier("int").is_err());
assert!(validate_ascii_abi_identifier("__private").is_err());
assert!(validate_ascii_abi_identifier("_Private").is_err());
assert!(validate_ascii_abi_identifier("_private").is_err());
}
#[test]
fn pointee_type_path_accepts_documented_example() {
assert_eq!(validate_rust_pointee_type_path("tree_sitter::ffi::TSLanguage"), Ok(()));
assert_eq!(validate_rust_pointee_type_path("MyRawType"), Ok(()));
}
#[test]
fn pointee_type_path_rejects_const_generic_block() {
assert!(validate_rust_pointee_type_path("Foo<{ std::process::exit(1) }>").is_err());
}
#[test]
fn pointee_type_path_rejects_qualified_path() {
assert!(validate_rust_pointee_type_path("<Foo as Trait>::Bar").is_err());
}
#[test]
fn pointee_type_path_rejects_garbage() {
assert!(validate_rust_pointee_type_path("not a type; std::process::exit(1)").is_err());
}
#[test]
fn cfg_expression_accepts_documented_example() {
assert_eq!(
validate_cfg_expression("all(target_os = \"android\", target_arch = \"x86_64\")"),
Ok(())
);
assert_eq!(validate_cfg_expression("unix"), Ok(()));
assert_eq!(validate_cfg_expression("not(windows)"), Ok(()));
assert_eq!(validate_cfg_expression("_custom"), Ok(()));
}
#[test]
fn cfg_expression_rejects_char_literal_quote_breakout() {
assert!(validate_cfg_expression("target_os = 'x'").is_err());
}
#[test]
fn cfg_expression_rejects_labeled_loop_quote_breakout() {
assert!(validate_cfg_expression("'a: loop { break }").is_err());
}
#[test]
fn cfg_expression_rejects_non_string_and_unknown_combinator() {
assert!(validate_cfg_expression("target_os = 1").is_err());
assert!(validate_cfg_expression("evil(target_os = \"x\")").is_err());
assert!(validate_cfg_expression("target_os = \"foo'bar\"").is_err());
assert!(validate_cfg_expression("not()").is_err());
assert!(validate_cfg_expression("not(unix, windows)").is_err());
}
#[test]
fn cargo_package_name_accepts_typical_names() {
assert_eq!(validate_cargo_package_name("tree-sitter"), Ok(()));
assert_eq!(validate_cargo_package_name("serde_json"), Ok(()));
}
#[test]
fn cargo_package_name_rejects_toml_table_injection() {
assert!(validate_cargo_package_name("x\"\n[patch.crates-io]\nfoo").is_err());
assert!(validate_cargo_package_name("x = \"1\"\n[dependencies]\ny").is_err());
assert!(validate_cargo_package_name("1starts-with-digit").is_err());
}
#[test]
fn cargo_version_req_accepts_typical_requirements() {
assert_eq!(validate_cargo_version_req("1.0"), Ok(()));
assert_eq!(validate_cargo_version_req("^1.2.3"), Ok(()));
}
#[test]
fn cargo_version_req_rejects_quote_breakout() {
assert!(validate_cargo_version_req("1.0\"\n[patch.crates-io]").is_err());
}
#[test]
fn cargo_feature_name_accepts_typical_names() {
assert_eq!(validate_cargo_feature_name("native-http"), Ok(()));
assert_eq!(validate_cargo_feature_name("android-target"), Ok(()));
}
#[test]
fn cargo_feature_name_rejects_dependency_feature_spec() {
assert!(validate_cargo_feature_name("other-pkg/evil-feature").is_err());
assert!(validate_cargo_feature_name("dep:evil").is_err());
assert!(validate_cargo_feature_name("evil\"]\nfoo").is_err());
}
}