fix: Temporal 日历注解白名单化,u-ca 校验扩到 18 项内置日历

This commit is contained in:
2026-08-16 15:19:45 +08:00
parent b4bd0d3198
commit 2777c5592b
+147 -25
View File
@@ -2683,7 +2683,7 @@ fn validate_temporal_annotation_suffix(mut suffix: &str) -> Result<(), String> {
if critical || saw_critical_calendar {
return Err("invalid calendar annotation".into());
}
} else if !value.eq_ignore_ascii_case("iso8601") {
} else if is_builtin_calendar_id(value).is_none() {
return Err("invalid calendar annotation".into());
} else {
calendar_seen = true;
@@ -2885,22 +2885,60 @@ fn parse_temporal_string_impl(input: &str, enforce_date_time_range: bool) -> Res
Ok((year, month, day, total_ns))
}
/// ParseTemporalCalendarString:日历标识符 = "iso8601"(ASCII 大小写不敏感)或合法 ISO
/// 日期(-时间)字符串(含部分日期 YYYY-MM / MM-DD,可选时间、偏移、注解)。
fn parse_temporal_calendar_string(input: &str) -> Result<(), String> {
/// 规范 BuiltinCalendarID 全集(18 项)。匹配用 eq_ignore_ascii_case(ASCII 折叠,
/// 禁 to_lowercase:U+0130 点 I 会被 Unicode 折叠成 i+组合符,误判为 iso8601)。
const CALENDAR_ID_WHITELIST: [&str; 18] = [
"buddhist",
"chinese",
"coptic",
"dangi",
"ethioaa",
"ethiopic",
"gregory",
"hebrew",
"indian",
"islamic",
"islamic-civil",
"islamic-rcy",
"islamic-tbla",
"islamic-umalqura",
"iso8601",
"japanese",
"persian",
"roc",
];
/// 严格白名单匹配:命中返回规范小写形式,未命中返回 None。
fn is_builtin_calendar_id(value: &str) -> Option<&'static str> {
CALENDAR_ID_WHITELIST.iter().find(|id| value.eq_ignore_ascii_case(id)).copied()
}
/// 严格日历 ID 解析:只走 18 项白名单,拒绝 ISO 串(含注解/compact/extended/空串)。
/// 供构造器日历参数与注解值判定使用。
fn parse_temporal_calendar_id_strict(input: &str) -> Result<String, String> {
let trimmed = input.trim();
is_builtin_calendar_id(trimmed)
.map(str::to_string)
.ok_or_else(|| "invalid calendar".to_string())
}
/// ParseTemporalCalendarString:日历标识符 = 18 项内置日历 ID(ASCII 大小写不敏感)
/// 或合法 ISO 日期(-时间)字符串(含部分日期 YYYY-MM / MM-DD,可选时间、偏移、注解)。
/// 返回规范化日历 ID:白名单命中返回规范小写,ISO 串路径恒为 "iso8601"。
fn parse_temporal_calendar_string(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("invalid calendar".into());
}
if trimmed.eq_ignore_ascii_case("iso8601") {
return Ok(());
if let Some(id) = is_builtin_calendar_id(trimmed) {
return Ok(id.to_string());
}
if trimmed.contains('\u{2212}') {
return Err("variant minus sign is not valid for calendar".into());
}
// 完整日期时间字符串(含注解校验),例如 2020-01-01T00:00:00.000000000[u-ca=iso8601]
if parse_plain_date_time_string(trimmed).is_ok() {
return Ok(());
return Ok("iso8601".to_string());
}
// 部分日期:YYYY-MM 或 MM-DD(可带注解)
let text = trimmed.to_owned();
@@ -2910,7 +2948,8 @@ fn parse_temporal_calendar_string(input: &str) -> Result<(), String> {
if text.contains(['Z', 'z']) {
return Err("UTC designator is not valid for calendar".into());
}
parse_partial_calendar_date(text)
parse_partial_calendar_date(text)?;
Ok("iso8601".to_string())
}
/// 部分 ISO 日期(无时间):YYYY[-MM[-DD]] 或 MM-DD;校验月份/日期基本范围并拒绝负零年。
@@ -2981,25 +3020,38 @@ fn parse_partial_calendar_date(input: &str) -> Result<(), String> {
Ok(())
}
/// ToTemporalCalendar 校验:非 string 抛 TypeError;字符串必须通过 ParseTemporalCalendarString。
fn temporal_calendar_check<H: VmHost>(vm: &mut H, value: JsValue) -> Result<(), JsValue> {
/// ToTemporalCalendar(宽松版,property bag 路径用):undefined → None;
/// string → 宽松解析(白名单 ID 或 ISO 串);其他类型 → TypeError。
/// 日历对象的内部槽 fast path 在日历槽接入后补充。
fn temporal_calendar_id<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_undefined() {
return Ok(());
return Ok(None);
}
if !value.is_string() {
return Err(crate::error::create_type_error(vm, "invalid calendar"));
if value.is_string() {
return parse_temporal_calendar_string(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
if parse_temporal_calendar_string(&to_string(value)).is_err() {
return Err(crate::error::create_range_error(vm, "invalid calendar"));
Err(crate::error::create_type_error(vm, "invalid calendar"))
}
/// ToTemporalCalendar 严格版(构造器日历参数用):字符串只走 18 项白名单,拒绝 ISO 串;
/// undefined / 其他分支与宽松版一致。
#[expect(dead_code)] // 构造器日历参数接入前的预留入口
fn temporal_calendar_id_strict<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_string() {
return parse_temporal_calendar_id_strict(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
Ok(())
temporal_calendar_id(vm, value)
}
fn plain_date_time_object_parts<H: VmHost>(
vm: &mut H, value: JsValue, obj: &JsObject, constrain: bool, ignore_time: bool,
) -> Result<(i32, u32, u32, f64), JsValue> {
let calendar = temporal_option_value(vm, obj, value, "calendar")?;
temporal_calendar_check(vm, calendar)?;
) -> Result<(i32, u32, u32, f64, Option<String>), JsValue> {
let calendar_raw = temporal_option_value(vm, obj, value, "calendar")?;
let calendar = temporal_calendar_id(vm, calendar_raw)?;
// 先按规范顺序读取全部原始字段,暂不转换类型;PlainDate 路径忽略时间字段。
let (
@@ -3151,7 +3203,7 @@ fn plain_date_time_object_parts<H: VmHost>(
if !ignore_time && !valid_plain_date_time_range(year, month, day, total_ns) {
return Err(crate::error::create_range_error(vm, "invalid date-time component"));
}
Ok((year, month, day, total_ns))
Ok((year, month, day, total_ns, calendar))
}
/// 将 ZonedDateTime 按时区偏移转换为本地 PlainDateTime 分量。
@@ -3176,9 +3228,10 @@ fn zoned_date_time_plain_parts<H: VmHost>(vm: &mut H, obj: &JsObject) -> Result<
fn plain_date_time_like_parts<H: VmHost>(
vm: &mut H, value: JsValue, constrain: bool,
) -> Result<(i32, u32, u32, f64), JsValue> {
) -> Result<(i32, u32, u32, f64, Option<String>), JsValue> {
if value.is_string() {
return parse_plain_date_time_string(&to_string(value))
.map(|(year, month, day, total_ns)| (year, month, day, total_ns, None))
.map_err(|_| crate::error::create_range_error(vm, "invalid ISO 8601 date-time"));
}
if !value.is_object() {
@@ -3195,6 +3248,7 @@ fn plain_date_time_like_parts<H: VmHost>(
get_double_prop(obj, 1) as u32,
get_double_prop(obj, 2) as u32,
get_double_prop(obj, 3),
None, // 实例日历传播在日历槽接入后补齐
));
}
if obj.is_plain_date_obj() {
@@ -3203,10 +3257,12 @@ fn plain_date_time_like_parts<H: VmHost>(
get_double_prop(obj, 1) as u32,
get_double_prop(obj, 2) as u32,
0.0,
None, // 实例日历传播在日历槽接入后补齐
));
}
if obj.is_zoned_date_time_obj() {
return zoned_date_time_plain_parts(vm, obj);
return zoned_date_time_plain_parts(vm, obj)
.map(|(year, month, day, total_ns)| (year, month, day, total_ns, None));
}
plain_date_time_object_parts(vm, value, obj, constrain, false)
}
@@ -3250,7 +3306,7 @@ pub fn plain_date_time_from<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult
make_plain_date_time(vm, year, month, day, total_ns)
} else {
let constrain = native_try!(temporal_overflow(vm, args));
let (year, month, day, total_ns) = native_try!(plain_date_time_like_parts(vm, value, constrain));
let (year, month, day, total_ns, _) = native_try!(plain_date_time_like_parts(vm, value, constrain));
make_plain_date_time(vm, year, month, day, total_ns)
}
}
@@ -4324,7 +4380,7 @@ fn plain_date_time_difference<H: VmHost>(vm: &mut H, args: &[u8], since: bool) -
Err(error) => return NativeResult::Err(error),
};
let other = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() };
let (oy, om, od, ot) = match plain_date_time_like_parts(vm, other, true) {
let (oy, om, od, ot, _) = match plain_date_time_like_parts(vm, other, true) {
Ok(parts) => parts,
Err(error) => return NativeResult::Err(error),
};
@@ -4419,7 +4475,7 @@ fn object_date_ymd<H: VmHost>(vm: &mut H, val: JsValue, constrain: bool) -> Resu
let (year, month, day, _) = zoned_date_time_plain_parts(vm, obj)?;
return Ok((year, month, day));
}
let (year, month, day, _) = plain_date_time_object_parts(vm, val, obj, constrain, true)?;
let (year, month, day, _, _) = plain_date_time_object_parts(vm, val, obj, constrain, true)?;
Ok((year, month, day))
}
@@ -4744,3 +4800,69 @@ pub fn plain_date_since<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult {
true,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_calendar_id_whitelist() {
// 18 项白名单全部命中且返回规范小写形式。
for id in CALENDAR_ID_WHITELIST {
assert_eq!(is_builtin_calendar_id(id), Some(id), "白名单项 {id} 应命中自身");
}
// ASCII 大小写折叠:仅首字母大写与全大写变体命中。
assert_eq!(is_builtin_calendar_id("IsO8601"), Some("iso8601"));
assert_eq!(is_builtin_calendar_id("HEBREW"), Some("hebrew"));
assert_eq!(is_builtin_calendar_id("Gregory"), Some("gregory"));
// 点 I(U+0130)不属于 ASCII 折叠域,必须拒绝,否则误判为 iso8601。
assert_eq!(is_builtin_calendar_id("\u{0130}SO8601"), None);
// 白名单外与类日期串拒绝。
assert_eq!(is_builtin_calendar_id("notacal"), None);
assert_eq!(is_builtin_calendar_id("1111-11-11"), None);
assert_eq!(is_builtin_calendar_id("11111111"), None);
}
#[test]
fn strict_calendar_id_parser() {
// 严格解析:只收白名单 ID,ISO 串 / 未知值 / 空串全拒。
assert_eq!(parse_temporal_calendar_id_strict("hebrew"), Ok("hebrew".to_string()));
assert_eq!(parse_temporal_calendar_id_strict("IsO8601"), Ok("iso8601".to_string()));
assert_eq!(parse_temporal_calendar_id_strict(" gregory "), Ok("gregory".to_string()));
assert_eq!(
parse_temporal_calendar_id_strict("1997-12-04[u-ca=iso8601]"),
Err("invalid calendar".to_string())
);
assert_eq!(parse_temporal_calendar_id_strict("11111111"), Err("invalid calendar".to_string()));
assert_eq!(parse_temporal_calendar_id_strict("\u{0130}SO8601"), Err("invalid calendar".to_string()));
assert_eq!(parse_temporal_calendar_id_strict(""), Err("invalid calendar".to_string()));
}
#[test]
fn loose_calendar_string_parser() {
// 宽松解析(property bag 路径):白名单 ID 返回规范小写,ISO 串路径恒为 iso8601。
assert_eq!(parse_temporal_calendar_string("gregory"), Ok("gregory".to_string()));
assert_eq!(parse_temporal_calendar_string("iSo8601"), Ok("iso8601".to_string()));
assert_eq!(parse_temporal_calendar_string("1997-12-04"), Ok("iso8601".to_string()));
assert_eq!(parse_temporal_calendar_string("1997-12-04[u-ca=iso8601]"), Ok("iso8601".to_string()));
assert_eq!(parse_temporal_calendar_string(""), Err("invalid calendar".to_string()));
// 注解值未过白名单 / 裸未知标识符,两种路径都拒绝。
assert!(parse_temporal_calendar_string("[u-ca=notacal]").is_err());
assert!(parse_temporal_calendar_string("notacal").is_err());
}
#[test]
fn annotation_suffix_calendar_whitelist() {
// 首个 u-ca 注解值过 18 项白名单:白名单内放行(含关键标记),白名单外拒绝。
assert!(validate_temporal_annotation_suffix("[u-ca=hebrew]").is_ok());
assert!(validate_temporal_annotation_suffix("[!u-ca=hebrew]").is_ok());
assert!(validate_temporal_annotation_suffix("[u-ca=iSo8601]").is_ok());
assert!(validate_temporal_annotation_suffix("[u-ca=notacal]").is_err());
assert!(validate_temporal_annotation_suffix("[u-ca=1111-11-11]").is_err());
// 点 I 变体不做 Unicode 折叠,拒绝。
assert!(validate_temporal_annotation_suffix("[u-ca=\u{0130}SO8601]").is_err());
// 第二及后续 u-ca 注解被忽略,不参与校验;含关键标记的重复日历报错。
assert!(validate_temporal_annotation_suffix("[u-ca=iso8601][u-ca=discord]").is_ok());
assert!(validate_temporal_annotation_suffix("[u-ca=iso8601][!u-ca=iso8601]").is_err());
}
}