use crate::codegen::identifier_grammar::{
is_csharp_identifier_part, is_csharp_identifier_start, is_java_identifier_part, is_java_identifier_start,
is_kotlin_identifier_part, is_kotlin_identifier_start, is_swift_identifier_part, is_swift_identifier_start,
};
const JAVA_RESERVED: &[&str] = &[
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"strictfp",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"try",
"void",
"volatile",
"while",
"true",
"false",
"null",
"_",
];
const KOTLIN_HARD_KEYWORDS: &[&str] = &[
"as",
"break",
"class",
"continue",
"do",
"else",
"false",
"for",
"fun",
"if",
"in",
"interface",
"is",
"null",
"object",
"package",
"return",
"super",
"this",
"throw",
"true",
"try",
"typealias",
"typeof",
"val",
"var",
"when",
"while",
];
struct SegmentGrammar {
language: &'static str,
is_start: fn(char) -> bool,
is_part: fn(char) -> bool,
start_hint: &'static str,
reserved: &'static [&'static str],
}
fn is_java_package_part(character: char) -> bool {
is_java_identifier_part(character) && !character.is_control()
}
const JAVA_SEGMENT_GRAMMAR: SegmentGrammar = SegmentGrammar {
language: "Java",
is_start: is_java_identifier_start,
is_part: is_java_package_part,
start_hint: "a letter, or a currency (`$`) or connector (`_`) character",
reserved: JAVA_RESERVED,
};
const KOTLIN_SEGMENT_GRAMMAR: SegmentGrammar = SegmentGrammar {
language: "Kotlin",
is_start: is_kotlin_identifier_start,
is_part: is_kotlin_identifier_part,
start_hint: "a letter (not a letter-number such as a Roman numeral) or `_`",
reserved: KOTLIN_HARD_KEYWORDS,
};
fn validate_package_segments(name: &str, grammar: &SegmentGrammar) -> Result<(), String> {
if name.is_empty() {
return Err("must not be empty".to_string());
}
for segment in name.split('.') {
validate_one_segment(name, segment, grammar)?;
}
Ok(())
}
fn validate_one_segment(name: &str, segment: &str, grammar: &SegmentGrammar) -> Result<(), String> {
let language = grammar.language;
if segment.is_empty() {
return Err(format!("`{name}` has an empty segment (leading/trailing/double dot)"));
}
let mut chars = segment.chars();
let first = chars.next().expect("segment is non-empty");
if !(grammar.is_start)(first) {
return Err(format!(
"segment `{segment}` in `{name}` must start with {}",
grammar.start_hint
));
}
if let Some(bad) = chars.find(|character| !(grammar.is_part)(*character)) {
return Err(format!(
"segment `{segment}` in `{name}` contains `{bad}`, which is not a valid {language} identifier character"
));
}
if grammar.reserved.contains(&segment) {
return Err(format!(
"segment `{segment}` in `{name}` is a {language} reserved word and cannot be used unescaped"
));
}
Ok(())
}
pub fn validate_java_package(name: &str) -> Result<(), String> {
validate_package_segments(name, &JAVA_SEGMENT_GRAMMAR)
}
pub fn validate_kotlin_package(name: &str) -> Result<(), String> {
validate_package_segments(name, &KOTLIN_SEGMENT_GRAMMAR)
}
pub fn validate_maven_coordinate(field: &str, value: &str) -> Result<(), String> {
if value.is_empty() {
return Err(format!("{field} must not be empty"));
}
if value.contains("..") || value.contains('/') || value.contains('\\') {
return Err(format!(
"{field} `{value}` must not contain `..`, `/`, or `\\` (Maven coordinates become repository path segments)"
));
}
if value.starts_with('.') || value.starts_with('-') || value.ends_with('.') || value.ends_with('-') {
return Err(format!("{field} `{value}` must not start or end with `.` or `-`"));
}
if let Some(bad) = value
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '.' || *c == '_' || *c == '-'))
{
return Err(format!(
"{field} `{value}` contains `{bad}`; Maven coordinates allow only ASCII letters, digits, `.`, `_`, and `-`"
));
}
Ok(())
}
pub fn validate_nuget_package_id(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("NuGet package ID must not be empty".to_string());
}
if value.encode_utf16().count() > 100 {
return Err(format!(
"NuGet package ID `{value}` exceeds the 100 UTF-16 code-unit limit"
));
}
if value.starts_with(['.', '-']) || value.ends_with(['.', '-']) {
return Err(format!(
"NuGet package ID `{value}` must not start or end with `.` or `-`"
));
}
if let Some(character) = value.chars().find(|character| character.len_utf16() != 1) {
return Err(format!(
"NuGet package ID `{value}` contains supplementary character `{character}`; NuGet's .NET regex evaluates UTF-16 code units and rejects surrogate pairs"
));
}
if let Some(bad) = value
.chars()
.find(|character| !is_dotnet_word_character(*character) && !matches!(character, '.' | '-'))
{
return Err(format!(
"NuGet package ID `{value}` contains `{bad}`; allowed characters are .NET word characters, `.`, and `-`"
));
}
if value
.as_bytes()
.windows(2)
.any(|pair| matches!(pair, [b'.' | b'-', b'.' | b'-']))
{
return Err(format!(
"NuGet package ID `{value}` must have a letter, digit, or `_` between `.`/`-` separators"
));
}
Ok(())
}
pub fn nuget_ordinal_fold(value: &str) -> String {
let mapper = icu_casemap::CaseMapper::new();
value
.chars()
.map(|character| {
let uppercase = mapper.simple_uppercase(character);
if !character.is_ascii() && uppercase.is_ascii() {
character
} else {
uppercase
}
})
.collect()
}
fn is_dotnet_word_character(character: char) -> bool {
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(
get_general_category(character),
GeneralCategory::LowercaseLetter
| GeneralCategory::UppercaseLetter
| GeneralCategory::TitlecaseLetter
| GeneralCategory::OtherLetter
| GeneralCategory::ModifierLetter
| GeneralCategory::NonspacingMark
| GeneralCategory::DecimalNumber
| GeneralCategory::ConnectorPunctuation
)
}
const CSHARP_RESERVED: &[&str] = &[
"abstract",
"as",
"base",
"bool",
"break",
"byte",
"case",
"catch",
"char",
"checked",
"class",
"const",
"continue",
"decimal",
"default",
"delegate",
"do",
"double",
"else",
"enum",
"event",
"explicit",
"extern",
"false",
"finally",
"fixed",
"float",
"for",
"foreach",
"goto",
"if",
"implicit",
"in",
"int",
"interface",
"internal",
"is",
"lock",
"long",
"namespace",
"new",
"null",
"object",
"operator",
"out",
"override",
"params",
"private",
"protected",
"public",
"readonly",
"ref",
"return",
"sbyte",
"sealed",
"short",
"sizeof",
"stackalloc",
"static",
"string",
"struct",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"uint",
"ulong",
"unchecked",
"unsafe",
"ushort",
"using",
"virtual",
"void",
"volatile",
"while",
];
const CSHARP_SEGMENT_GRAMMAR: SegmentGrammar = SegmentGrammar {
language: "C#",
is_start: is_csharp_identifier_start,
is_part: is_csharp_identifier_part,
start_hint: "a letter or `_`",
reserved: CSHARP_RESERVED,
};
pub fn validate_csharp_namespace(name: &str) -> Result<(), String> {
validate_package_segments(name, &CSHARP_SEGMENT_GRAMMAR)
}
const SWIFT_RESERVED: &[&str] = &[
"associatedtype",
"class",
"deinit",
"enum",
"extension",
"fileprivate",
"func",
"import",
"init",
"inout",
"internal",
"let",
"open",
"operator",
"private",
"protocol",
"public",
"rethrows",
"static",
"struct",
"subscript",
"typealias",
"var",
"break",
"case",
"continue",
"default",
"defer",
"do",
"else",
"fallthrough",
"for",
"guard",
"if",
"in",
"repeat",
"return",
"switch",
"where",
"while",
"as",
"any",
"catch",
"false",
"is",
"nil",
"self",
"super",
"throw",
"throws",
"true",
"try",
];
pub fn validate_swift_module_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("must not be empty".to_string());
}
let mut chars = name.chars();
let first = chars.next().expect("name is non-empty");
if !is_swift_identifier_start(first) {
return Err(format!("`{name}` must start with a letter or `_`"));
}
if let Some(bad) = chars.find(|c| !is_swift_identifier_part(*c)) {
return Err(format!(
"`{name}` contains `{bad}`; a Swift module name has no internal `.`/`-` structure and must \
be a single identifier"
));
}
if SWIFT_RESERVED.contains(&name) {
return Err(format!(
"`{name}` is a Swift reserved keyword and cannot be used unescaped"
));
}
Ok(())
}
pub fn validate_swift_package_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("must not be empty".to_string());
}
if let Some(bad) = name.chars().find(|c| *c == '"' || *c == '\\' || c.is_control()) {
return Err(format!(
"`{name}` contains `{bad}`, which would break out of the Swift string literal `Package(name: \"...\")` \
is spliced into"
));
}
Ok(())
}
const DART_RESERVED: &[&str] = &[
"assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum", "extends",
"false", "final", "finally", "for", "if", "in", "is", "new", "null", "rethrow", "return", "super", "switch",
"this", "throw", "true", "try", "var", "void", "while", "with",
];
pub fn validate_dart_package_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("must not be empty".to_string());
}
let mut chars = name.chars();
let first = chars.next().expect("name is non-empty");
if !first.is_ascii_lowercase() {
return Err(format!("`{name}` must start with a lowercase ASCII letter"));
}
if let Some(bad) = chars.find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_')) {
return Err(format!(
"`{name}` contains `{bad}`; pub.dev package names allow only lowercase ASCII letters, digits, and `_`"
));
}
if DART_RESERVED.contains(&name) {
return Err(format!(
"`{name}` is a Dart reserved word and cannot be used as a package name"
));
}
Ok(())
}
pub fn validate_dart_library_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("must not be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(format!(
"`{name}` must be a single path component, with no `/`, `\\`, or `..`"
));
}
if name.ends_with(['.', ' ']) {
return Err(format!("`{name}` must not end with a dot or space"));
}
let device_stem = name.split('.').next().unwrap_or(name);
let uppercase_stem = device_stem.to_ascii_uppercase();
let is_numbered_device = uppercase_stem
.strip_prefix("COM")
.or_else(|| uppercase_stem.strip_prefix("LPT"))
.is_some_and(|suffix| {
matches!(
suffix,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
});
if matches!(uppercase_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || is_numbered_device {
return Err(format!("`{name}` uses a Windows-reserved file basename"));
}
if let Some(bad) = name
.chars()
.find(|c| matches!(*c, '\'' | '$' | '<' | '>' | ':' | '"' | '|' | '?' | '*') || c.is_control())
{
return Err(format!(
"`{name}` contains `{bad}`, which is not valid in a portable file basename or the \
Dart import string literal `import 'package:...'` it is spliced into"
));
}
Ok(())
}
pub fn kotlin_string_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_and_kotlin_packages_accept_typical_defaults() {
for value in ["dev.sample_core", "com.github.foo_org", "unconfigured.alef"] {
assert!(validate_java_package(value).is_ok());
assert!(validate_kotlin_package(value).is_ok());
}
}
#[test]
fn jvm_package_accepts_unicode_letters() {
assert!(
validate_java_package("München.parser").is_ok(),
"non-ASCII letters are legal Java identifiers"
);
}
#[test]
fn jvm_package_rejects_empty_and_double_dot_segments() {
assert!(validate_java_package("").is_err());
assert!(validate_java_package("dev..example").is_err());
assert!(validate_java_package(".dev.example").is_err());
assert!(validate_java_package("dev.example.").is_err());
}
#[test]
fn jvm_package_rejects_path_traversal_segments() {
assert!(validate_java_package("../../etc").is_err());
}
#[test]
fn java_and_kotlin_package_grammars_are_distinct_and_case_sensitive() {
assert!(validate_java_package("dev.class").is_err());
assert!(validate_java_package("dev.Class").is_ok());
assert!(validate_java_package("dev.fun").is_ok());
assert!(validate_java_package("dev.$internal").is_ok());
assert!(validate_kotlin_package("dev.class").is_err());
assert!(validate_kotlin_package("dev.Class").is_ok());
assert!(validate_kotlin_package("dev.fun").is_err());
assert!(validate_kotlin_package("dev.$internal").is_err());
}
#[test]
fn java_package_accepts_restricted_identifiers() {
assert!(
validate_java_package("dev.var").is_ok(),
"var is a restricted identifier, not a keyword"
);
assert!(
validate_java_package("dev.yield").is_ok(),
"yield is a restricted identifier, not a keyword"
);
assert!(
validate_java_package("dev.record").is_ok(),
"record is a restricted identifier, not a keyword"
);
}
#[test]
fn jvm_package_rejects_injection_characters() {
assert!(validate_java_package("dev\";System.exit(1);//").is_err());
assert!(validate_kotlin_package("dev.example;import evil.Class").is_err());
}
#[test]
fn maven_coordinate_accepts_hyphenated_group_ids() {
assert!(validate_maven_coordinate("groupId", "io.projectreactor.netty").is_ok());
assert!(validate_maven_coordinate("artifactId", "my-lib-android").is_ok());
}
#[test]
fn maven_coordinate_rejects_empty_and_path_traversal() {
assert!(validate_maven_coordinate("groupId", "").is_err());
assert!(validate_maven_coordinate("groupId", "../../evil").is_err());
assert!(validate_maven_coordinate("groupId", "dev/example").is_err());
assert!(validate_maven_coordinate("groupId", ".dev").is_err());
assert!(validate_maven_coordinate("groupId", "dev.").is_err());
}
#[test]
fn maven_coordinate_rejects_quote_and_interpolation_characters() {
assert!(validate_maven_coordinate("groupId", "dev\"); System.exit(1); //").is_err());
assert!(validate_maven_coordinate("groupId", "dev${evil}").is_err());
}
#[test]
fn nuget_package_id_accepts_typical_ids() {
assert!(validate_nuget_package_id("MyCompany.MyLib").is_ok());
assert!(validate_nuget_package_id("my_lib-android").is_ok());
}
#[test]
fn nuget_package_id_matches_dotnet_word_character_categories() {
assert!(validate_nuget_package_id("Cafe\u{301}").is_ok());
assert!(validate_nuget_package_id("My\u{203f}Lib").is_ok());
assert!(validate_nuget_package_id("München.Δοκιμή").is_ok());
assert!(validate_nuget_package_id("Ⅻ").is_err());
assert!(validate_nuget_package_id("²").is_err());
}
#[test]
fn nuget_package_id_limit_counts_dotnet_utf16_code_units() {
assert!(validate_nuget_package_id("𐐀").is_err());
assert!(validate_nuget_package_id(&"𐐀".repeat(51)).is_err());
}
#[test]
fn nuget_package_id_rejects_empty_leading_dot_and_quotes() {
assert!(validate_nuget_package_id("").is_err());
assert!(validate_nuget_package_id(".MyLib").is_err());
assert!(validate_nuget_package_id("MyLib\"><Evil/>").is_err());
assert!(validate_nuget_package_id("MyLib.").is_err());
assert!(validate_nuget_package_id("MyLib-.Core").is_err());
assert!(validate_nuget_package_id(&"a".repeat(101)).is_err());
assert!(validate_nuget_package_id(&"a".repeat(100)).is_ok());
}
#[test]
fn nuget_ordinal_fold_matches_case_insensitively() {
assert_eq!(nuget_ordinal_fold("MyLib"), nuget_ordinal_fold("mylib"));
assert_eq!(nuget_ordinal_fold("MyLib"), nuget_ordinal_fold("MYLIB"));
assert_ne!(nuget_ordinal_fold("MyLib"), nuget_ordinal_fold("MyLib2"));
assert_ne!(nuget_ordinal_fold("ẞ"), nuget_ordinal_fold("ß"));
assert_ne!(nuget_ordinal_fold("ſ"), nuget_ordinal_fold("S"));
assert_ne!(nuget_ordinal_fold("K"), nuget_ordinal_fold("K"));
}
#[test]
fn nuget_ordinal_fold_does_not_expand_a_character_into_several() {
assert_eq!(nuget_ordinal_fold("weiß"), "WEIß");
assert_eq!(nuget_ordinal_fold("weiß").chars().count(), "weiß".chars().count());
}
#[test]
fn dart_library_name_rejects_non_portable_file_basenames() {
for name in [
"CON", "com1", "Lpt9", "COM¹", "com²", "LPT³", "name:", "name*", "name?", "name\"", "name<", "name>",
"name|",
] {
assert!(validate_dart_library_name(name).is_err(), "accepted {name:?}");
}
assert!(validate_dart_library_name("trailing.").is_err());
assert!(validate_dart_library_name("trailing ").is_err());
assert!(validate_dart_library_name("sample-widget").is_ok());
}
#[test]
fn csharp_namespace_accepts_unicode_letters() {
assert!(validate_csharp_namespace("München.Parser").is_ok());
}
#[test]
fn csharp_namespace_rejects_reserved_words_and_empty_segments() {
assert!(validate_csharp_namespace("My.class").is_err());
assert!(validate_csharp_namespace("My..Lib").is_err());
assert!(validate_csharp_namespace("").is_err());
}
#[test]
fn csharp_namespace_rejects_msbuild_and_quote_characters() {
assert!(validate_csharp_namespace("My\"><Evil/>").is_err());
assert!(validate_csharp_namespace("My.$(Evil)").is_err());
}
#[test]
fn swift_module_name_accepts_typical_names() {
assert!(validate_swift_module_name("SampleCore").is_ok());
assert!(validate_swift_module_name("_SampleCore").is_ok());
}
#[test]
fn swift_module_name_rejects_dots_dashes_and_reserved_words() {
assert!(validate_swift_module_name("Sample.Core").is_err());
assert!(validate_swift_module_name("sample-core").is_err());
assert!(validate_swift_module_name("class").is_err());
assert!(validate_swift_module_name("").is_err());
}
#[test]
fn swift_module_name_rejects_interpolation_characters() {
assert!(validate_swift_module_name("Sample\\(evilCode)").is_err());
}
#[test]
fn swift_module_name_keyword_check_is_case_sensitive() {
assert!(validate_swift_module_name("Class").is_ok());
assert!(validate_swift_module_name("class").is_err());
}
#[test]
fn swift_module_name_accepts_emoji() {
assert!(validate_swift_module_name("\u{1F600}").is_ok());
assert!(validate_swift_module_name("\u{1F389}").is_ok());
}
#[test]
fn swift_package_name_accepts_kebab_case_and_dotted_names() {
assert!(validate_swift_package_name("swift-argument-parser").is_ok());
assert!(validate_swift_package_name("Sample.Router").is_ok());
assert!(validate_swift_package_name("Sample Router").is_ok());
}
#[test]
fn swift_package_name_rejects_quote_and_backslash_but_not_dots() {
assert!(validate_swift_package_name("Sample\"); print(\"evil").is_err());
assert!(validate_swift_package_name("Sample\\(evilCode)").is_err());
assert!(validate_swift_package_name("").is_err());
}
#[test]
fn swift_package_name_rejects_control_characters() {
for value in ["Sample\nInjected", "Sample\rInjected", "Sample\0Injected"] {
assert!(
validate_swift_package_name(value).is_err(),
"`{value:?}` must be rejected"
);
}
}
#[test]
fn dart_package_name_accepts_lowercase_with_underscores() {
assert!(validate_dart_package_name("sample_core").is_ok());
}
#[test]
fn dart_package_name_rejects_uppercase_dashes_and_reserved_words() {
assert!(validate_dart_package_name("SampleCore").is_err());
assert!(validate_dart_package_name("sample-core").is_err());
assert!(validate_dart_package_name("var").is_err());
assert!(validate_dart_package_name("").is_err());
}
#[test]
fn dart_library_name_accepts_hyphens_that_the_package_name_grammar_rejects() {
assert!(validate_dart_library_name("sample-widget").is_ok());
assert!(validate_dart_package_name("sample-widget").is_err());
}
#[test]
fn dart_library_name_rejects_path_traversal_and_string_breakout_characters() {
assert!(validate_dart_library_name("").is_err());
assert!(validate_dart_library_name("../evil").is_err());
assert!(validate_dart_library_name("a/b").is_err());
assert!(validate_dart_library_name("a\\b").is_err());
assert!(validate_dart_library_name("evil'; import 'dart:io").is_err());
assert!(validate_dart_library_name("evil${1+1}").is_err());
}
#[test]
fn kotlin_string_escape_neutralizes_dollar_template_interpolation() {
assert_eq!(kotlin_string_escape("dev.$evilVar"), "dev.\\$evilVar");
assert_eq!(kotlin_string_escape("dev.${evil()}"), "dev.\\${evil()}");
}
#[test]
fn kotlin_string_escape_neutralizes_quotes_and_backslashes() {
assert_eq!(
kotlin_string_escape("dev\"); System.exit(1); //"),
"dev\\\"); System.exit(1); //"
);
assert_eq!(kotlin_string_escape("dev\\example"), "dev\\\\example");
}
#[test]
fn kotlin_string_escape_is_identity_for_a_plain_valid_package() {
let plain = "dev.sample_core";
assert_eq!(kotlin_string_escape(plain), plain);
}
}