diff --git a/LICENSE b/LICENSE index 5df4dd88b..ea89f609c 100644 --- a/LICENSE +++ b/LICENSE @@ -20,8 +20,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE -The file src/builtins/time/diff.rs contains code derived from Go's `time` -package, which carries the following license: +The files src/builtins/time/diff.rs and src/builtins/strings/go_is_print.rs +contain code derived from Go's standard library, which carries the following +license: Copyright (c) 2009 The Go Authors. All rights reserved. diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index fd3a2ee8b..2c0b00e5b 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -20,6 +20,9 @@ use crate::*; use anyhow::{bail, Result}; +mod go_is_print; +mod sprintf_format; + pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) { m.insert("concat", (concat, 2)); m.insert("contains", (contains, 2)); @@ -217,6 +220,7 @@ fn to_string(v: &Value, unescape: bool) -> String { } } +#[derive(Clone, Copy)] enum Width { None, LeadingZeros(usize), @@ -224,6 +228,37 @@ enum Width { Decimals(usize), } +#[derive(Clone, Copy, Default)] +struct FormatFlags { + alternate: bool, + zero: bool, + plus: bool, + minus: bool, + space: bool, +} + +#[derive(Clone, Copy, Default)] +struct FormatSpec { + flags: FormatFlags, + width: Option, + precision: Option, + bad_precision: bool, + bad_index: bool, +} + +impl FormatSpec { + fn legacy_width(self) -> Width { + match (self.width, self.precision) { + (_, Some(precision)) => Width::Decimals(precision), + (Some(width), None) if self.flags.zero && !self.flags.minus => { + Width::LeadingZeros(width) + } + (Some(width), None) => Width::Cell(width), + (None, None) => Width::None, + } + } +} + fn apply_width(w: Width, s: String) -> String { match w { Width::LeadingZeros(n) if n > s.len() => "0".repeat(n - s.len()) + &s, @@ -232,6 +267,290 @@ fn apply_width(w: Width, s: String) -> String { } } +const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + +// Append a string quoted like Go's fmt `%q`. Precision truncates the input by +// Unicode scalar values before quoting, while width pads the quoted result by +// Unicode scalar values. `%+q` forces ASCII escapes and `%#q` uses a raw string +// whenever strconv.CanBackquote permits it. +fn append_go_quoted(out: &mut String, input: &str, spec: FormatSpec) -> Result<()> { + let input = spec + .precision + .map_or(input, |precision| truncate_chars(input, precision)); + let raw = spec.flags.alternate && can_backquote(input); + let content_len = if raw { + input.chars().count().saturating_add(2) + } else { + go_quoted_len(input, spec.flags.plus, '"') + }; + let padding = spec.width.unwrap_or_default().saturating_sub(content_len); + let padding_char = if spec.flags.zero && !spec.flags.minus { + '0' + } else { + ' ' + }; + + if !spec.flags.minus { + append_padding(out, padding, padding_char)?; + } + + if raw { + out.push('`'); + for c in input.chars() { + out.push(c); + enforce_limit()?; + } + out.push('`'); + enforce_limit()?; + } else { + append_go_quoted_body(out, input, spec.flags.plus, '"')?; + } + + if spec.flags.minus { + append_padding(out, padding, ' ')?; + } + Ok(()) +} + +fn append_go_quoted_body( + out: &mut String, + input: &str, + ascii_only: bool, + quote: char, +) -> Result<()> { + out.push(quote); + for c in input.chars() { + match c { + c if c == quote => { + out.push('\\'); + out.push(c); + } + '\\' => out.push_str("\\\\"), + '\u{0007}' => out.push_str("\\a"), + '\u{0008}' => out.push_str("\\b"), + '\u{000C}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{000B}' => out.push_str("\\v"), + c if go_is_print::is_print(c) && (!ascii_only || c.is_ascii()) => out.push(c), + c if (c as u32) <= 0x7f => append_hex_escape(out, 'x', c as u32, 2), + c if (c as u32) <= 0xffff => append_hex_escape(out, 'u', c as u32, 4), + c => append_hex_escape(out, 'U', c as u32, 8), + } + enforce_limit()?; + } + out.push(quote); + enforce_limit() +} + +fn append_padding(out: &mut String, count: usize, padding_char: char) -> Result<()> { + for _ in 0..count { + out.push(padding_char); + enforce_limit()?; + } + Ok(()) +} + +fn can_backquote(s: &str) -> bool { + s.chars() + .all(|c| c != '`' && c != '\u{FEFF}' && c != '\u{007F}' && (c >= ' ' || c == '\t')) +} + +fn truncate_chars(s: &str, count: usize) -> &str { + s.char_indices() + .nth(count) + .map_or(s, |(byte_index, _)| &s[..byte_index]) +} + +fn go_quoted_len(s: &str, ascii_only: bool, quote: char) -> usize { + s.chars().fold(2usize, |len, c| { + let escaped_len = match c { + c if c == quote => 2, + '\\' | '\u{0007}' | '\u{0008}' | '\u{000C}' | '\n' | '\r' | '\t' | '\u{000B}' => 2, + c if go_is_print::is_print(c) && (!ascii_only || c.is_ascii()) => 1, + c if (c as u32) <= 0x7f => 4, + c if (c as u32) <= 0xffff => 6, + _ => 10, + }; + len.saturating_add(escaped_len) + }) +} + +fn append_go_quoted_rune(out: &mut String, value: i64, spec: FormatSpec) -> Result<()> { + let rune = u32::try_from(value) + .ok() + .and_then(char::from_u32) + .unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + let rune = rune.encode_utf8(&mut encoded); + let content_len = go_quoted_len(rune, spec.flags.plus, '\''); + let padding = spec.width.unwrap_or_default().saturating_sub(content_len); + let padding_char = if spec.flags.zero && !spec.flags.minus { + '0' + } else { + ' ' + }; + + if !spec.flags.minus { + append_padding(out, padding, padding_char)?; + } + append_go_quoted_body(out, rune, spec.flags.plus, '\'')?; + if spec.flags.minus { + append_padding(out, padding, ' ')?; + } + Ok(()) +} + +fn append_go_quoted_value(out: &mut String, value: &Value, spec: FormatSpec) -> Result<()> { + match value { + Value::String(value) => append_go_quoted(out, value.as_ref(), spec), + Value::Number(Number::Int(value)) => append_go_quoted_rune(out, *value, spec), + Value::Number(Number::UInt(value)) if *value <= i64::MAX as u64 => { + append_go_quoted_rune(out, *value as i64, spec) + } + Value::Number(number @ (Number::UInt(_) | Number::BigInt(_))) => { + out.push_str("%!q(big.Int="); + out.push_str(&number.format_decimal()); + out.push(')'); + enforce_limit() + } + Value::Number(Number::Float(value)) => { + out.push_str("%!q(float64="); + append_go_float_value(out, value, spec)?; + out.push(')'); + enforce_limit() + } + value => { + let mut rendered = String::new(); + append_value_string(&mut rendered, value, false)?; + append_go_quoted(out, &rendered, spec) + } + } +} + +// Go renders an invalid %q float operand using the supplied flags, width, and +// precision as a nested %v conversion. +fn append_go_float_value(out: &mut String, value: &f64, spec: FormatSpec) -> Result<()> { + let value = if let Some(precision) = spec.precision { + format_go_float_v(*value, precision, spec.flags.alternate) + } else { + format_go_float_v(*value, 6, spec.flags.alternate) + }; + let padding = spec.width.unwrap_or_default().saturating_sub(value.len()); + let padding_char = if spec.flags.zero && !spec.flags.minus { + '0' + } else { + ' ' + }; + if !spec.flags.minus { + append_padding(out, padding, padding_char)?; + } + out.push_str(&value); + enforce_limit()?; + if spec.flags.minus { + append_padding(out, padding, ' ')?; + } + Ok(()) +} + +// Go's %v precision for floats is the number of significant digits, using the +// same general-format threshold as %g. The alternate form retains trailing +// zeroes up to that precision. +fn format_go_float_v(value: f64, precision: usize, alternate: bool) -> String { + if !value.is_finite() || value == 0.0 { + return value.to_string(); + } + let precision = precision.max(1); + let exponent = value.abs().log10().floor() as i32; + let scientific = exponent >= precision as i32 || exponent < -4; + let mut rendered = if scientific { + let fraction_digits = precision - 1; + let rendered = format!("{value:.fraction_digits$e}"); + let (mantissa, exponent) = rendered + .split_once('e') + .expect("scientific format has exponent"); + let mantissa = if alternate { + mantissa.to_owned() + } else { + mantissa + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned() + }; + let exponent = exponent.parse::().expect("Rust exponent is numeric"); + format!("{mantissa}e{exponent:+03}") + } else { + let fraction_digits = (precision as i32 - exponent - 1).max(0) as usize; + format!("{value:.fraction_digits$}") + }; + if !alternate && !scientific { + rendered = rendered + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned(); + } + rendered +} + +fn append_value_string(out: &mut String, value: &Value, unescape: bool) -> Result<()> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => out.push_str(&value.to_string()), + Value::String(value) if unescape => out.push_str( + &serde_json::to_string(value.as_ref()).unwrap_or_else(|_| value.as_ref().to_string()), + ), + Value::String(value) => out.push_str(value.as_ref()), + Value::Number(value) => out.push_str(&value.format_decimal()), + Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + append_value_string(out, value, true)?; + enforce_limit()?; + } + out.push(']'); + } + Value::Set(values) => { + out.push('{'); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + append_value_string(out, value, true)?; + enforce_limit()?; + } + out.push('}'); + } + Value::Object(values) => { + out.push('{'); + for (index, (key, value)) in values.iter_sorted().enumerate() { + if index > 0 { + out.push_str(", "); + } + append_value_string(out, key, true)?; + out.push_str(": "); + append_value_string(out, value, true)?; + enforce_limit()?; + } + out.push('}'); + } + Value::Undefined => out.push_str("#undefined"), + } + enforce_limit() +} + +fn append_hex_escape(out: &mut String, prefix: char, value: u32, digits: usize) { + out.push('\\'); + out.push(prefix); + for digit in (0..digits).rev() { + let nibble = ((value >> (digit * 4)) & 0x0f) as usize; + out.push(LOWER_HEX[nibble] as char); + } +} + fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> Result { let name = "sprintf"; ensure_args_count(span, name, params, args, 2)?; @@ -240,57 +559,57 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> let mut s = String::default(); let mut args_idx = 0usize; - let mut chars = fmt.chars().peekable(); + let mut cursor = 0usize; + let mut reordered = false; let args_span = params[1].span(); + let format_span = params[0].span(); loop { - let (verb, width) = match chars.next() { - Some('%') => match chars.next() { - Some('%') => { - s.push('%'); - continue; - } - Some(c) if c == '.' || c.is_numeric() => { - let first_char = c; - let mut w = 0; - if c != '.' { - w = c.to_digit(10).expect("could not get digit from char"); - } - - while chars.peek().map(|c| c.is_numeric()) == Some(true) { - w = w * 10 - + chars - .next() - .expect("could not get next digit") - .to_digit(10) - .expect("could not get digit from char"); - } - let width = match first_char { - '0' => Width::LeadingZeros(w as usize), - '.' => Width::Decimals(w as usize), - _ => Width::Cell(w as usize), - }; - match chars.next() { - Some(c) => (c, width), - _ => { - let span = params[0].span(); - bail!(span.error( - "missing format verb after `%width` at end of format string" - )); - } - } - } - Some(c) => (c, Width::None), - None => { - let span = params[0].span(); - bail!(span.error("missing format verb after `%` at end of format string")); - } - }, - Some(c) => { - s.push(c); - continue; - } - None => break, + let Some(percent_offset) = fmt[cursor..].find('%') else { + s.push_str(&fmt[cursor..]); + enforce_limit()?; + break; }; + let percent = cursor + percent_offset; + s.push_str(&fmt[cursor..percent]); + enforce_limit()?; + + let (spec, verb, next_cursor) = sprintf_format::parse( + fmt.as_ref(), + percent + 1, + args.as_ref(), + &mut args_idx, + &mut reordered, + args_span, + format_span, + )?; + cursor = next_cursor; + + if verb == '%' { + s.push('%'); + enforce_limit()?; + continue; + } + + if spec.bad_index { + s.push_str("%!"); + s.push(verb); + s.push_str("(BADINDEX)"); + enforce_limit()?; + continue; + } + + if verb != 'q' + && (spec.flags.alternate || spec.flags.plus || spec.flags.minus || spec.flags.space) + { + bail!(format_span.error( + "sprintf flags '#', '+', '-' and space are currently supported only for %q" + )); + } + + if verb != 'q' && spec.width.is_some() && spec.precision.is_some() { + bail!(format_span + .error("sprintf combined width and precision are currently supported only for %q")); + } if args_idx >= args.len() { bail!(args_span @@ -298,13 +617,15 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> } let arg = &args[args_idx]; args_idx += 1; + if spec.bad_precision { + s.push_str("%!(BADPREC)"); + enforce_limit()?; + } + let width = spec.legacy_width(); // Handle Golang flags. let emit_sign = false; let leave_space_for_elided_sign = false; - // Note: Golang flags come BEFORE the format verb, not after. - // This code was incorrectly consuming characters after the verb. - // Removing the incorrect flag handling to fix sprintf spacing. let get_sign_value = |f: &Number| match (emit_sign, f) { (_, v) if v < &Number::from(0.0) => ("-", v.clone()), @@ -403,23 +724,20 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> s += format!("{v}").as_str() } } + ('q', value) => append_go_quoted_value(&mut s, value, spec)?, + (_, Value::Number(_)) => { bail!(args_span.error(&format!("number specified for format verb {verb}."))); } - ('+', _) if chars.next() == Some('v') => { - bail!(args_span.error("Go-syntax fields names format verm %#v is not supported.")); - } - ('T', _) | ('#', _) | ('q', _) | ('p', _) => { - bail!( - args_span.error("Go-syntax format verbs %#v. %q, %p and %T are not supported.") - ); + ('T', _) | ('p', _) => { + bail!(args_span.error("Go-syntax format verbs %#v, %p and %T are not supported.")); } _ => {} } } - if args_idx < args.len() { + if !reordered && args_idx < args.len() { bail!(args_span.error( format!( "extra arguments ({}) specified for {args_idx} format verbs.", @@ -675,3 +993,133 @@ fn upper(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> Re let s = ensure_string(name, ¶ms[0], &args[0])?; Ok(Value::String(s.to_uppercase().into())) } + +#[cfg(test)] +mod tests { + use super::*; + + fn go_quote_string(s: &str) -> String { + let mut out = String::new(); + append_go_quoted(&mut out, s, FormatSpec::default()).expect("quoting must succeed"); + out + } + + // Reference values below were captured from `sprintf("%q", [...])` + // evaluated with OPA (github.com/open-policy-agent/opa), which in turn + // delegates to Go's `strconv.Quote`. + #[test] + fn quote_string_matches_go_strconv_quote() { + assert_eq!(go_quote_string("foo"), "\"foo\""); + assert_eq!(go_quote_string(""), "\"\""); + assert_eq!(go_quote_string("a\"b"), "\"a\\\"b\""); + assert_eq!(go_quote_string("back\\slash"), "\"back\\\\slash\""); + assert_eq!(go_quote_string("tab\there"), "\"tab\\there\""); + assert_eq!(go_quote_string("nl\nhere"), "\"nl\\nhere\""); + assert_eq!(go_quote_string("cr\rhere"), "\"cr\\rhere\""); + assert_eq!(go_quote_string("emoji\u{1F642}"), "\"emoji\u{1F642}\""); + assert_eq!( + go_quote_string("\u{044E}\u{043D}\u{0456}\u{043A}\u{043E}\u{0434}"), + "\"\u{044E}\u{043D}\u{0456}\u{043A}\u{043E}\u{0434}\"" + ); + // %q does NOT HTML-escape < > & (unlike json.marshal). + assert_eq!(go_quote_string("a&c"), "\"a&c\""); + + // Short escapes for the other named control characters. + assert_eq!(go_quote_string("\u{0007}"), "\"\\a\""); + assert_eq!(go_quote_string("\u{0008}"), "\"\\b\""); + assert_eq!(go_quote_string("\u{000C}"), "\"\\f\""); + assert_eq!(go_quote_string("\u{000B}"), "\"\\v\""); + + // Other C0 control characters fall back to \xNN. + assert_eq!(go_quote_string("x\u{001F}y"), "\"x\\x1fy\""); + // DEL (0x7f) is also escaped as \x7f. + assert_eq!(go_quote_string("x\u{007F}y"), "\"x\\x7fy\""); + // Non-breaking space is a non-ASCII-space separator: not printable, + // and within the BMP so it uses \uNNNN. + assert_eq!(go_quote_string("x\u{00A0}y"), "\"x\\u00a0y\""); + // Format, private-use, noncharacter, and unassigned scalars are not + // printable under Go's Unicode category definition. + assert_eq!(go_quote_string("x\u{00AD}y"), "\"x\\u00ady\""); + assert_eq!(go_quote_string("x\u{200B}y"), "\"x\\u200by\""); + assert_eq!(go_quote_string("x\u{E000}y"), "\"x\\ue000y\""); + assert_eq!(go_quote_string("x\u{FDD0}y"), "\"x\\ufdd0y\""); + assert_eq!(go_quote_string("x\u{0378}y"), "\"x\\u0378y\""); + // Astral-plane printable characters are left as-is. + assert_eq!(go_quote_string("x\u{1F600}y"), "\"x\u{1F600}y\""); + } + + #[test] + fn quote_string_applies_supported_width_and_precision() { + let quote = |input, spec| { + let mut out = String::new(); + append_go_quoted(&mut out, input, spec).expect("quoting must succeed"); + out + }; + + assert_eq!( + quote( + "foo", + FormatSpec { + width: Some(10), + ..FormatSpec::default() + } + ), + " \"foo\"" + ); + assert_eq!( + quote( + "a", + FormatSpec { + flags: FormatFlags { + zero: true, + ..FormatFlags::default() + }, + width: Some(5), + precision: None, + ..FormatSpec::default() + } + ), + "00\"a\"" + ); + assert_eq!( + quote( + "abcdef", + FormatSpec { + precision: Some(3), + ..FormatSpec::default() + } + ), + "\"abc\"" + ); + assert_eq!( + quote( + "abc", + FormatSpec { + precision: Some(0), + ..FormatSpec::default() + } + ), + "\"\"" + ); + assert_eq!( + quote( + "\u{1F642}", + FormatSpec { + width: Some(6), + ..FormatSpec::default() + } + ), + " \"\u{1F642}\"" + ); + assert_eq!( + quote( + "\u{1F642}x", + FormatSpec { + precision: Some(1), + ..FormatSpec::default() + } + ), + "\"\u{1F642}\"" + ); + } +} diff --git a/src/builtins/strings/go_is_print.rs b/src/builtins/strings/go_is_print.rs new file mode 100644 index 000000000..b3945eb13 --- /dev/null +++ b/src/builtins/strings/go_is_print.rs @@ -0,0 +1,200 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Adapted from Go 1.27.1's generated strconv/isprint.go tables (Unicode 17.0.0). +// Keep this data synchronized with the Go version used as the OPA compatibility +// reference. The four tables below occupy 3,592 bytes. + +const IS_PRINT_16: &[u16] = &[ + 0x0020, 0x007e, 0x00a1, 0x0377, 0x037a, 0x037f, 0x0384, 0x0556, 0x0559, 0x058a, 0x058d, 0x05c7, + 0x05d0, 0x05ea, 0x05ef, 0x05f4, 0x0606, 0x070d, 0x0710, 0x074a, 0x074d, 0x07b1, 0x07c0, 0x07fa, + 0x07fd, 0x082d, 0x0830, 0x085b, 0x085e, 0x086a, 0x0870, 0x088f, 0x0897, 0x098c, 0x098f, 0x0990, + 0x0993, 0x09b2, 0x09b6, 0x09b9, 0x09bc, 0x09c4, 0x09c7, 0x09c8, 0x09cb, 0x09ce, 0x09d7, 0x09d7, + 0x09dc, 0x09e3, 0x09e6, 0x09fe, 0x0a01, 0x0a0a, 0x0a0f, 0x0a10, 0x0a13, 0x0a39, 0x0a3c, 0x0a42, + 0x0a47, 0x0a48, 0x0a4b, 0x0a4d, 0x0a51, 0x0a51, 0x0a59, 0x0a5e, 0x0a66, 0x0a76, 0x0a81, 0x0ab9, + 0x0abc, 0x0acd, 0x0ad0, 0x0ad0, 0x0ae0, 0x0ae3, 0x0ae6, 0x0af1, 0x0af9, 0x0b0c, 0x0b0f, 0x0b10, + 0x0b13, 0x0b39, 0x0b3c, 0x0b44, 0x0b47, 0x0b48, 0x0b4b, 0x0b4d, 0x0b55, 0x0b57, 0x0b5c, 0x0b63, + 0x0b66, 0x0b77, 0x0b82, 0x0b8a, 0x0b8e, 0x0b95, 0x0b99, 0x0b9f, 0x0ba3, 0x0ba4, 0x0ba8, 0x0baa, + 0x0bae, 0x0bb9, 0x0bbe, 0x0bc2, 0x0bc6, 0x0bcd, 0x0bd0, 0x0bd0, 0x0bd7, 0x0bd7, 0x0be6, 0x0bfa, + 0x0c00, 0x0c39, 0x0c3c, 0x0c4d, 0x0c55, 0x0c5d, 0x0c60, 0x0c63, 0x0c66, 0x0c6f, 0x0c77, 0x0cb9, + 0x0cbc, 0x0ccd, 0x0cd5, 0x0cd6, 0x0cdc, 0x0ce3, 0x0ce6, 0x0cf3, 0x0d00, 0x0d4f, 0x0d54, 0x0d63, + 0x0d66, 0x0d96, 0x0d9a, 0x0dbd, 0x0dc0, 0x0dc6, 0x0dca, 0x0dca, 0x0dcf, 0x0ddf, 0x0de6, 0x0def, + 0x0df2, 0x0df4, 0x0e01, 0x0e3a, 0x0e3f, 0x0e5b, 0x0e81, 0x0ebd, 0x0ec0, 0x0ed9, 0x0edc, 0x0edf, + 0x0f00, 0x0f6c, 0x0f71, 0x0fda, 0x1000, 0x10c7, 0x10cd, 0x10cd, 0x10d0, 0x124d, 0x1250, 0x125d, + 0x1260, 0x128d, 0x1290, 0x12b5, 0x12b8, 0x12c5, 0x12c8, 0x1315, 0x1318, 0x135a, 0x135d, 0x137c, + 0x1380, 0x1399, 0x13a0, 0x13f5, 0x13f8, 0x13fd, 0x1400, 0x169c, 0x16a0, 0x16f8, 0x1700, 0x1715, + 0x171f, 0x1736, 0x1740, 0x1753, 0x1760, 0x1773, 0x1780, 0x17dd, 0x17e0, 0x17e9, 0x17f0, 0x17f9, + 0x1800, 0x1819, 0x1820, 0x1878, 0x1880, 0x18aa, 0x18b0, 0x18f5, 0x1900, 0x192b, 0x1930, 0x193b, + 0x1940, 0x1940, 0x1944, 0x196d, 0x1970, 0x1974, 0x1980, 0x19ab, 0x19b0, 0x19c9, 0x19d0, 0x19da, + 0x19de, 0x1a1b, 0x1a1e, 0x1a7c, 0x1a7f, 0x1a89, 0x1a90, 0x1a99, 0x1aa0, 0x1aad, 0x1ab0, 0x1add, + 0x1ae0, 0x1aeb, 0x1b00, 0x1bf3, 0x1bfc, 0x1c37, 0x1c3b, 0x1c49, 0x1c4d, 0x1c8a, 0x1c90, 0x1cba, + 0x1cbd, 0x1cc7, 0x1cd0, 0x1cfa, 0x1d00, 0x1f15, 0x1f18, 0x1f1d, 0x1f20, 0x1f45, 0x1f48, 0x1f4d, + 0x1f50, 0x1f7d, 0x1f80, 0x1fd3, 0x1fd6, 0x1fef, 0x1ff2, 0x1ffe, 0x2010, 0x2027, 0x2030, 0x205e, + 0x2070, 0x2071, 0x2074, 0x209c, 0x20a0, 0x20c1, 0x20d0, 0x20f0, 0x2100, 0x218b, 0x2190, 0x2429, + 0x2440, 0x244a, 0x2460, 0x2b73, 0x2b76, 0x2cf3, 0x2cf9, 0x2d27, 0x2d2d, 0x2d2d, 0x2d30, 0x2d67, + 0x2d6f, 0x2d70, 0x2d7f, 0x2d96, 0x2da0, 0x2e5d, 0x2e80, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0, 0x3096, + 0x3099, 0x30ff, 0x3105, 0x31e5, 0x31ef, 0xa48c, 0xa490, 0xa4c6, 0xa4d0, 0xa62b, 0xa640, 0xa6f7, + 0xa700, 0xa7dc, 0xa7f1, 0xa82c, 0xa830, 0xa839, 0xa840, 0xa877, 0xa880, 0xa8c5, 0xa8ce, 0xa8d9, + 0xa8e0, 0xa953, 0xa95f, 0xa97c, 0xa980, 0xa9d9, 0xa9de, 0xaa36, 0xaa40, 0xaa4d, 0xaa50, 0xaa59, + 0xaa5c, 0xaac2, 0xaadb, 0xaaf6, 0xab01, 0xab06, 0xab09, 0xab0e, 0xab11, 0xab16, 0xab20, 0xab6b, + 0xab70, 0xabed, 0xabf0, 0xabf9, 0xac00, 0xd7a3, 0xd7b0, 0xd7c6, 0xd7cb, 0xd7fb, 0xf900, 0xfa6d, + 0xfa70, 0xfad9, 0xfb00, 0xfb06, 0xfb13, 0xfb17, 0xfb1d, 0xfdcf, 0xfdf0, 0xfe19, 0xfe20, 0xfe6b, + 0xfe70, 0xfefc, 0xff01, 0xffbe, 0xffc2, 0xffc7, 0xffca, 0xffcf, 0xffd2, 0xffd7, 0xffda, 0xffdc, + 0xffe0, 0xffee, 0xfffc, 0xfffd, +]; + +const IS_NOT_PRINT_16: &[u16] = &[ + 0x00ad, 0x038b, 0x038d, 0x03a2, 0x0530, 0x0590, 0x061c, 0x06dd, 0x083f, 0x085f, 0x08e2, 0x0984, + 0x09a9, 0x09b1, 0x09de, 0x0a04, 0x0a29, 0x0a31, 0x0a34, 0x0a37, 0x0a3d, 0x0a5d, 0x0a84, 0x0a8e, + 0x0a92, 0x0aa9, 0x0ab1, 0x0ab4, 0x0ac6, 0x0aca, 0x0b00, 0x0b04, 0x0b29, 0x0b31, 0x0b34, 0x0b5e, + 0x0b84, 0x0b91, 0x0b9b, 0x0b9d, 0x0bc9, 0x0c0d, 0x0c11, 0x0c29, 0x0c45, 0x0c49, 0x0c57, 0x0c5b, + 0x0c8d, 0x0c91, 0x0ca9, 0x0cb4, 0x0cc5, 0x0cc9, 0x0cdf, 0x0cf0, 0x0d0d, 0x0d11, 0x0d45, 0x0d49, + 0x0d80, 0x0d84, 0x0db2, 0x0dbc, 0x0dd5, 0x0dd7, 0x0e83, 0x0e85, 0x0e8b, 0x0ea4, 0x0ea6, 0x0ec5, + 0x0ec7, 0x0ecf, 0x0f48, 0x0f98, 0x0fbd, 0x0fcd, 0x10c6, 0x1249, 0x1257, 0x1259, 0x1289, 0x12b1, + 0x12bf, 0x12c1, 0x12d7, 0x1311, 0x1680, 0x176d, 0x1771, 0x180e, 0x191f, 0x1a5f, 0x1b4d, 0x1f58, + 0x1f5a, 0x1f5c, 0x1f5e, 0x1fb5, 0x1fc5, 0x1fdc, 0x1ff5, 0x208f, 0x2d26, 0x2da7, 0x2daf, 0x2db7, + 0x2dbf, 0x2dc7, 0x2dcf, 0x2dd7, 0x2ddf, 0x2e9a, 0x3000, 0x3040, 0x3130, 0x318f, 0x321f, 0xa9ce, + 0xa9ff, 0xab27, 0xab2f, 0xfb37, 0xfb3d, 0xfb3f, 0xfb42, 0xfb45, 0xfe53, 0xfe67, 0xfe75, 0xffe7, +]; + +const IS_PRINT_32: &[u32] = &[ + 0x010000, 0x01004d, 0x010050, 0x01005d, 0x010080, 0x0100fa, 0x010100, 0x010102, 0x010107, + 0x010133, 0x010137, 0x01019c, 0x0101a0, 0x0101a0, 0x0101d0, 0x0101fd, 0x010280, 0x01029c, + 0x0102a0, 0x0102d0, 0x0102e0, 0x0102fb, 0x010300, 0x010323, 0x01032d, 0x01034a, 0x010350, + 0x01037a, 0x010380, 0x0103c3, 0x0103c8, 0x0103d5, 0x010400, 0x01049d, 0x0104a0, 0x0104a9, + 0x0104b0, 0x0104d3, 0x0104d8, 0x0104fb, 0x010500, 0x010527, 0x010530, 0x010563, 0x01056f, + 0x0105bc, 0x0105c0, 0x0105f3, 0x010600, 0x010736, 0x010740, 0x010755, 0x010760, 0x010767, + 0x010780, 0x0107ba, 0x010800, 0x010805, 0x010808, 0x010838, 0x01083c, 0x01083c, 0x01083f, + 0x01089e, 0x0108a7, 0x0108af, 0x0108e0, 0x0108f5, 0x0108fb, 0x01091b, 0x01091f, 0x010939, + 0x01093f, 0x010959, 0x010980, 0x0109b7, 0x0109bc, 0x0109cf, 0x0109d2, 0x010a06, 0x010a0c, + 0x010a35, 0x010a38, 0x010a3a, 0x010a3f, 0x010a48, 0x010a50, 0x010a58, 0x010a60, 0x010a9f, + 0x010ac0, 0x010ae6, 0x010aeb, 0x010af6, 0x010b00, 0x010b35, 0x010b39, 0x010b55, 0x010b58, + 0x010b72, 0x010b78, 0x010b91, 0x010b99, 0x010b9c, 0x010ba9, 0x010baf, 0x010c00, 0x010c48, + 0x010c80, 0x010cb2, 0x010cc0, 0x010cf2, 0x010cfa, 0x010d27, 0x010d30, 0x010d39, 0x010d40, + 0x010d65, 0x010d69, 0x010d85, 0x010d8e, 0x010d8f, 0x010e60, 0x010ead, 0x010eb0, 0x010eb1, + 0x010ec2, 0x010ec7, 0x010ed0, 0x010ed8, 0x010efa, 0x010f27, 0x010f30, 0x010f59, 0x010f70, + 0x010f89, 0x010fb0, 0x010fcb, 0x010fe0, 0x010ff6, 0x011000, 0x01104d, 0x011052, 0x011075, + 0x01107f, 0x0110c2, 0x0110d0, 0x0110e8, 0x0110f0, 0x0110f9, 0x011100, 0x011147, 0x011150, + 0x011176, 0x011180, 0x0111f4, 0x011200, 0x011241, 0x011280, 0x0112a9, 0x0112b0, 0x0112ea, + 0x0112f0, 0x0112f9, 0x011300, 0x01130c, 0x01130f, 0x011310, 0x011313, 0x011344, 0x011347, + 0x011348, 0x01134b, 0x01134d, 0x011350, 0x011350, 0x011357, 0x011357, 0x01135d, 0x011363, + 0x011366, 0x01136c, 0x011370, 0x011374, 0x011380, 0x01138b, 0x01138e, 0x0113c2, 0x0113c5, + 0x0113d8, 0x0113e1, 0x0113e2, 0x011400, 0x011461, 0x011480, 0x0114c7, 0x0114d0, 0x0114d9, + 0x011580, 0x0115b5, 0x0115b8, 0x0115dd, 0x011600, 0x011644, 0x011650, 0x011659, 0x011660, + 0x01166c, 0x011680, 0x0116b9, 0x0116c0, 0x0116c9, 0x0116d0, 0x0116e3, 0x011700, 0x01171a, + 0x01171d, 0x01172b, 0x011730, 0x011746, 0x011800, 0x01183b, 0x0118a0, 0x0118f2, 0x0118ff, + 0x011906, 0x011909, 0x011909, 0x01190c, 0x011938, 0x01193b, 0x011946, 0x011950, 0x011959, + 0x0119a0, 0x0119a7, 0x0119aa, 0x0119d7, 0x0119da, 0x0119e4, 0x011a00, 0x011a47, 0x011a50, + 0x011aa2, 0x011ab0, 0x011af8, 0x011b00, 0x011b09, 0x011b60, 0x011b67, 0x011bc0, 0x011be1, + 0x011bf0, 0x011bf9, 0x011c00, 0x011c45, 0x011c50, 0x011c6c, 0x011c70, 0x011c8f, 0x011c92, + 0x011cb6, 0x011d00, 0x011d36, 0x011d3a, 0x011d47, 0x011d50, 0x011d59, 0x011d60, 0x011d98, + 0x011da0, 0x011da9, 0x011db0, 0x011ddb, 0x011de0, 0x011de9, 0x011ee0, 0x011ef8, 0x011f00, + 0x011f3a, 0x011f3e, 0x011f5a, 0x011fb0, 0x011fb0, 0x011fc0, 0x011ff1, 0x011fff, 0x012399, + 0x012400, 0x012474, 0x012480, 0x012543, 0x012f90, 0x012ff2, 0x013000, 0x01342f, 0x013440, + 0x013455, 0x013460, 0x0143fa, 0x014400, 0x014646, 0x016100, 0x016139, 0x016800, 0x016a38, + 0x016a40, 0x016a69, 0x016a6e, 0x016ac9, 0x016ad0, 0x016aed, 0x016af0, 0x016af5, 0x016b00, + 0x016b45, 0x016b50, 0x016b77, 0x016b7d, 0x016b8f, 0x016d40, 0x016d79, 0x016e40, 0x016e9a, + 0x016ea0, 0x016eb8, 0x016ebb, 0x016ed3, 0x016f00, 0x016f4a, 0x016f4f, 0x016f87, 0x016f8f, + 0x016f9f, 0x016fe0, 0x016fe4, 0x016ff0, 0x016ff6, 0x017000, 0x018cd5, 0x018cff, 0x018d1e, + 0x018d80, 0x018df2, 0x01aff0, 0x01b122, 0x01b132, 0x01b132, 0x01b150, 0x01b152, 0x01b155, + 0x01b155, 0x01b164, 0x01b167, 0x01b170, 0x01b2fb, 0x01bc00, 0x01bc6a, 0x01bc70, 0x01bc7c, + 0x01bc80, 0x01bc88, 0x01bc90, 0x01bc99, 0x01bc9c, 0x01bc9f, 0x01cc00, 0x01ccfc, 0x01cd00, + 0x01ceb3, 0x01ceba, 0x01ced0, 0x01cee0, 0x01cef0, 0x01cf00, 0x01cf2d, 0x01cf30, 0x01cf46, + 0x01cf50, 0x01cfc3, 0x01d000, 0x01d0f5, 0x01d100, 0x01d126, 0x01d129, 0x01d172, 0x01d17b, + 0x01d1ea, 0x01d200, 0x01d245, 0x01d2c0, 0x01d2d3, 0x01d2e0, 0x01d2f3, 0x01d300, 0x01d356, + 0x01d360, 0x01d378, 0x01d400, 0x01d49f, 0x01d4a2, 0x01d4a2, 0x01d4a5, 0x01d4a6, 0x01d4a9, + 0x01d50a, 0x01d50d, 0x01d546, 0x01d54a, 0x01d6a5, 0x01d6a8, 0x01d7cb, 0x01d7ce, 0x01da8b, + 0x01da9b, 0x01daaf, 0x01df00, 0x01df1e, 0x01df25, 0x01df2a, 0x01e000, 0x01e018, 0x01e01b, + 0x01e02a, 0x01e030, 0x01e06d, 0x01e08f, 0x01e08f, 0x01e100, 0x01e12c, 0x01e130, 0x01e13d, + 0x01e140, 0x01e149, 0x01e14e, 0x01e14f, 0x01e290, 0x01e2ae, 0x01e2c0, 0x01e2f9, 0x01e2ff, + 0x01e2ff, 0x01e4d0, 0x01e4f9, 0x01e5d0, 0x01e5fa, 0x01e5ff, 0x01e5ff, 0x01e6c0, 0x01e6f5, + 0x01e6fe, 0x01e6ff, 0x01e7e0, 0x01e8c4, 0x01e8c7, 0x01e8d6, 0x01e900, 0x01e94b, 0x01e950, + 0x01e959, 0x01e95e, 0x01e95f, 0x01ec71, 0x01ecb4, 0x01ed01, 0x01ed3d, 0x01ee00, 0x01ee24, + 0x01ee27, 0x01ee3b, 0x01ee42, 0x01ee42, 0x01ee47, 0x01ee54, 0x01ee57, 0x01ee64, 0x01ee67, + 0x01ee9b, 0x01eea1, 0x01eebb, 0x01eef0, 0x01eef1, 0x01f000, 0x01f02b, 0x01f030, 0x01f093, + 0x01f0a0, 0x01f0ae, 0x01f0b1, 0x01f0f5, 0x01f100, 0x01f1ad, 0x01f1e6, 0x01f202, 0x01f210, + 0x01f23b, 0x01f240, 0x01f248, 0x01f250, 0x01f251, 0x01f260, 0x01f265, 0x01f300, 0x01f6d8, + 0x01f6dc, 0x01f6ec, 0x01f6f0, 0x01f6fc, 0x01f700, 0x01f7d9, 0x01f7e0, 0x01f7eb, 0x01f7f0, + 0x01f7f0, 0x01f800, 0x01f80b, 0x01f810, 0x01f847, 0x01f850, 0x01f859, 0x01f860, 0x01f887, + 0x01f890, 0x01f8ad, 0x01f8b0, 0x01f8bb, 0x01f8c0, 0x01f8c1, 0x01f8d0, 0x01f8d8, 0x01f900, + 0x01fa57, 0x01fa60, 0x01fa6d, 0x01fa70, 0x01fa7c, 0x01fa80, 0x01fa8a, 0x01fa8e, 0x01fac8, + 0x01facd, 0x01fadc, 0x01fadf, 0x01faea, 0x01faef, 0x01faf8, 0x01fb00, 0x01fbfa, 0x020000, + 0x02a6df, 0x02a700, 0x02b81d, 0x02b820, 0x02cead, 0x02ceb0, 0x02ebe0, 0x02ebf0, 0x02ee5d, + 0x02f800, 0x02fa1d, 0x030000, 0x03134a, 0x031350, 0x033479, 0x0e0100, 0x0e01ef, +]; + +const IS_NOT_PRINT_32: &[u16] = &[ + // Add 0x10000 to each entry. + 0x000c, 0x0027, 0x003b, 0x003e, 0x018f, 0x039e, 0x057b, 0x058b, 0x0593, 0x0596, 0x05a2, 0x05b2, + 0x05ba, 0x0786, 0x07b1, 0x0809, 0x0836, 0x0856, 0x08f3, 0x0a04, 0x0a14, 0x0a18, 0x0e7f, 0x0eaa, + 0x10bd, 0x1135, 0x11e0, 0x1212, 0x1287, 0x1289, 0x128e, 0x129e, 0x1304, 0x1329, 0x1331, 0x1334, + 0x133a, 0x138a, 0x138f, 0x13b6, 0x13c1, 0x13c6, 0x13cb, 0x13d6, 0x145c, 0x1914, 0x1917, 0x1936, + 0x1c09, 0x1c37, 0x1ca8, 0x1d07, 0x1d0a, 0x1d3b, 0x1d3e, 0x1d66, 0x1d69, 0x1d8f, 0x1d92, 0x1f11, + 0x246f, 0x6a5f, 0x6abf, 0x6b5a, 0x6b62, 0xaff4, 0xaffc, 0xafff, 0xd455, 0xd49d, 0xd4ad, 0xd4ba, + 0xd4bc, 0xd4c4, 0xd506, 0xd515, 0xd51d, 0xd53a, 0xd53f, 0xd545, 0xd551, 0xdaa0, 0xe007, 0xe022, + 0xe025, 0xe6df, 0xe7e7, 0xe7ec, 0xe7ef, 0xe7ff, 0xee04, 0xee20, 0xee23, 0xee28, 0xee33, 0xee38, + 0xee3a, 0xee48, 0xee4a, 0xee4c, 0xee50, 0xee53, 0xee58, 0xee5a, 0xee5c, 0xee5e, 0xee60, 0xee63, + 0xee6b, 0xee73, 0xee78, 0xee7d, 0xee7f, 0xee8a, 0xeea4, 0xeeaa, 0xf0c0, 0xf0d0, 0xfac7, 0xfb93, +]; + +/// Reports whether `c` is printable according to Go's `strconv.IsPrint`. +/// +/// This intentionally does not use Rust's `char` classification: Go limits +/// printable runes to Unicode categories L, M, N, P, and S plus ASCII space. +pub(super) fn is_print(c: char) -> bool { + let r = c as u32; + + // Fast path for Latin-1, matching strconv.IsPrint exactly. + if r <= 0xff { + if (0x20..=0x7e).contains(&r) { + return true; + } + if (0xa1..=0xff).contains(&r) { + return r != 0xad; + } + return false; + } + + if r < 1 << 16 { + let r = r as u16; + return in_ranges(IS_PRINT_16, r) && IS_NOT_PRINT_16.binary_search(&r).is_err(); + } + + if !in_ranges(IS_PRINT_32, r) { + return false; + } + if r >= 0x20000 { + return true; + } + + IS_NOT_PRINT_32 + .binary_search(&((r - 0x10000) as u16)) + .is_err() +} + +fn in_ranges(ranges: &[T], value: T) -> bool { + let index = match ranges.binary_search(&value) { + Ok(index) | Err(index) => index, + }; + index < ranges.len() && value >= ranges[index & !1] && value <= ranges[index | 1] +} + +#[cfg(test)] +mod tests { + use super::is_print; + + #[test] + fn excludes_non_printable_unicode_categories() { + for c in ['\u{00ad}', '\u{200b}', '\u{e000}', '\u{fdd0}', '\u{0378}'] { + assert!(!is_print(c), "U+{:04X} must be escaped", c as u32); + } + } + + #[test] + fn includes_go_printable_categories() { + for c in [' ', 'A', '\u{0301}', '\u{2160}', '!', '\u{1f642}'] { + assert!(is_print(c), "U+{:04X} must remain printable", c as u32); + } + } +} diff --git a/src/builtins/strings/sprintf_format.rs b/src/builtins/strings/sprintf_format.rs new file mode 100644 index 000000000..9f4773cf1 --- /dev/null +++ b/src/builtins/strings/sprintf_format.rs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::lexer::Span; +use crate::value::{Array, Value}; + +use alloc::format; +use anyhow::{anyhow, bail, Result}; + +use super::FormatSpec; + +const MAX_FORMAT_VALUE: usize = 1_000_000; + +fn parse_usize(bytes: &[u8], cursor: &mut usize) -> Result> { + let start = *cursor; + let mut value = 0usize; + while *cursor < bytes.len() && bytes[*cursor].is_ascii_digit() { + value = value + .checked_mul(10) + .and_then(|value| value.checked_add((bytes[*cursor] - b'0') as usize)) + .filter(|value| *value <= MAX_FORMAT_VALUE) + .ok_or_else(|| anyhow!("sprintf width or precision is too large"))?; + *cursor += 1; + } + Ok((*cursor != start).then_some(value)) +} + +fn parse_index(bytes: &[u8], cursor: &mut usize) -> Result> { + if bytes.get(*cursor) != Some(&b'[') { + return Ok(None); + } + let mut end = *cursor + 1; + let Some(index) = parse_usize(bytes, &mut end)? else { + bail!("sprintf argument index must contain a decimal number"); + }; + if bytes.get(end) != Some(&b']') || index == 0 { + bail!("invalid sprintf argument index"); + } + *cursor = end + 1; + Ok(Some(index - 1)) +} + +fn take_integer(args: &Array, args_idx: &mut usize, args_span: &Span) -> Result { + let index = *args_idx; + let Some(value) = args.get(index) else { + bail!(args_span.error(format!("no argument specified for format verb {index}").as_str())); + }; + *args_idx += 1; + match value { + Value::Number(number) if number.is_integer() => number.as_i64().ok_or_else(|| { + args_span.error("sprintf width or precision is outside the supported range") + }), + _ => bail!(args_span.error("sprintf width or precision must be an integer")), + } +} + +fn checked_dynamic_value(value: u64, args_span: &Span) -> Result { + usize::try_from(value) + .ok() + .filter(|value| *value <= MAX_FORMAT_VALUE) + .ok_or_else(|| args_span.error("sprintf width or precision is outside the supported range")) +} + +pub(super) fn parse( + format: &str, + start: usize, + args: &Array, + args_idx: &mut usize, + reordered: &mut bool, + args_span: &Span, + format_span: &Span, +) -> Result<(FormatSpec, char, usize)> { + let bytes = format.as_bytes(); + let mut cursor = start; + let mut spec = FormatSpec::default(); + + while cursor < bytes.len() { + match bytes[cursor] { + b'#' => spec.flags.alternate = true, + b'0' => spec.flags.zero = true, + b'+' => spec.flags.plus = true, + b'-' => spec.flags.minus = true, + b' ' => spec.flags.space = true, + _ => break, + } + cursor += 1; + } + + let args_idx_before_index = *args_idx; + if let Some(index) = parse_index(bytes, &mut cursor)? { + *args_idx = index; + *reordered = true; + if matches!(bytes.get(cursor), Some(b'0'..=b'9' | b'.')) { + spec.bad_index = true; + *args_idx = args_idx_before_index; + // A malformed explicit index neither consumes nor reorders an + // argument, but it suppresses the legacy extra-argument check. + *reordered = true; + } + } + + if bytes.get(cursor) == Some(&b'*') { + cursor += 1; + let width = take_integer(args, args_idx, args_span)?; + if width < 0 { + spec.flags.minus = true; + spec.flags.zero = false; + spec.width = Some(checked_dynamic_value(width.unsigned_abs(), args_span)?); + } else { + spec.width = Some(checked_dynamic_value(width as u64, args_span)?); + } + } else { + spec.width = parse_usize(bytes, &mut cursor)?; + } + + if bytes.get(cursor) == Some(&b'.') { + cursor += 1; + if let Some(index) = parse_index(bytes, &mut cursor)? { + *args_idx = index; + *reordered = true; + } + if bytes.get(cursor) == Some(&b'*') { + cursor += 1; + let precision = take_integer(args, args_idx, args_span)?; + if precision >= 0 { + spec.precision = Some(checked_dynamic_value(precision as u64, args_span)?); + } else { + spec.bad_precision = true; + } + } else { + spec.precision = Some(parse_usize(bytes, &mut cursor)?.unwrap_or_default()); + } + } + + if let Some(index) = parse_index(bytes, &mut cursor)? { + *args_idx = index; + *reordered = true; + } + + let Some(rest) = format.get(cursor..) else { + bail!(format_span.error("invalid byte offset in sprintf format string")); + }; + let Some(verb) = rest.chars().next() else { + bail!(format_span.error("missing format verb at end of format string")); + }; + Ok((spec, verb, cursor + verb.len_utf8())) +} diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index 1933fbc7a..75884bd22 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -77,4 +77,148 @@ cases: # This should cause an error - missing argument error_case := sprintf("Value: %s %d", ["only_one"]) query: data.test - error: "no argument specified for format verb 1" \ No newline at end of file + error: "no argument specified for format verb 1" + + - note: "%q string quoting (strconv.Quote / Go-syntax quoted string)" + data: {} + modules: + - | + package test + + plain := sprintf("%q", ["foo"]) + double_quote := sprintf("%q", ["a\"b"]) + backslash := sprintf("%q", ["back\\slash"]) + tab := sprintf("%q", ["tab\there"]) + newline := sprintf("%q", ["nl\nhere"]) + carriage_return := sprintf("%q", ["cr\rhere"]) + emoji := sprintf("%q", ["emoji🙂"]) + non_ascii := sprintf("%q", ["юнікод"]) + # %q must NOT HTML-escape < > & (that is what distinguishes it from + # json.marshal). + angle_and_amp := sprintf("%q", ["a&c"]) + soft_hyphen := sprintf("%q", ["\u00ad"]) + zero_width_space := sprintf("%q", ["\u200b"]) + line_separator := sprintf("%q", ["a\u2028b"]) + paragraph_separator := sprintf("%q", ["a\u2029b"]) + private_use := sprintf("%q", ["\ue000"]) + noncharacter := sprintf("%q", ["\ufdd0"]) + unassigned := sprintf("%q", ["\u0378"]) + empty := sprintf("%q", [""]) + query: data.test + want_result: + plain: "\"foo\"" + double_quote: "\"a\\\"b\"" + backslash: "\"back\\\\slash\"" + tab: "\"tab\\there\"" + newline: "\"nl\\nhere\"" + carriage_return: "\"cr\\rhere\"" + emoji: "\"emoji🙂\"" + non_ascii: "\"юнікод\"" + angle_and_amp: "\"a&c\"" + soft_hyphen: "\"\\u00ad\"" + zero_width_space: "\"\\u200b\"" + line_separator: "\"a\\u2028b\"" + paragraph_separator: "\"a\\u2029b\"" + private_use: "\"\\ue000\"" + noncharacter: "\"\\ufdd0\"" + unassigned: "\"\\u0378\"" + empty: "\"\"" + + - note: "%q width and precision" + data: {} + modules: + - | + package test + + padded := sprintf("%10q", ["foo"]) + zero_padded := sprintf("%05q", ["a"]) + truncated := sprintf("%.3q", ["abcdef"]) + empty := sprintf("%.0q", ["abcdef"]) + unicode_width := sprintf("%6q", ["🙂"]) + unicode_precision := sprintf("%.1q", ["🙂x"]) + query: data.test + want_result: + padded: " \"foo\"" + zero_padded: "00\"a\"" + truncated: "\"abc\"" + empty: "\"\"" + unicode_width: " \"🙂\"" + unicode_precision: "\"🙂\"" + + - note: "%q full string format specification" + data: {} + modules: + - | + package test + + ascii_only := sprintf("%+q", ["é🙂"]) + raw := sprintf("%#q", ["a\tb"]) + raw_fallback := sprintf("%#q", ["a\nb"]) + left_aligned := sprintf("%-8q", ["é"]) + width_and_precision := sprintf("%8.1q", ["éx"]) + ascii_width_and_precision := sprintf("%+14.1q", ["éx"]) + raw_width_and_precision := sprintf("%#8.2q", ["abx"]) + dynamic := sprintf("%*.*q", [8, 1, "éx"]) + indexed := sprintf("%[3]*.[2]*[1]q", ["éx", 1, 8]) + query: data.test + want_result: + ascii_only: "\"\\u00e9\\U0001f642\"" + raw: "`a\tb`" + raw_fallback: "\"a\\nb\"" + left_aligned: "\"é\" " + width_and_precision: " \"é\"" + ascii_width_and_precision: " \"\\u00e9\"" + raw_width_and_precision: " `ab`" + dynamic: " \"é\"" + indexed: " \"é\"" + + - note: "%q follows OPA value conversion semantics" + data: {} + modules: + - | + package test + + rune := sprintf("%q", [97]) + control_rune := sprintf("%q", [0]) + quote_rune := sprintf("%q", [39]) + invalid_rune := sprintf("%q", [-1]) + ascii_rune := sprintf("%+q", [233]) + padded_rune := sprintf("%8q", [97]) + fractional := sprintf("%q", [65.5]) + fractional_width := sprintf("%8q", [65.5]) + fractional_precision := sprintf("%.2q", [65.5]) + fractional_zero_padded := sprintf("%08q", [65.5]) + fractional_alternate := sprintf("%#q", [65.5]) + bad_dynamic_precision := sprintf("%.*q", [-1, "x"]) + bad_index := sprintf("%[1]2q", ["x"]) + bad_index_precision := sprintf("%[1].2q", ["x"]) + bad_index_does_not_consume := sprintf("%[2]2q %q", ["x"]) + big_integer := sprintf("%q", [9223372036854775808]) + boolean := sprintf("%q", [true]) + null_value := sprintf("%q", [null]) + array := sprintf("%q", [[1, "x"]]) + set := sprintf("%q", [{true, 1, "x"}]) + object := sprintf("%q", [{"b": 2, "a": 1}]) + query: data.test + want_result: + rune: "'a'" + control_rune: "'\\x00'" + quote_rune: "'\\''" + invalid_rune: "'�'" + ascii_rune: "'\\u00e9'" + padded_rune: " 'a'" + fractional: "%!q(float64=65.5)" + fractional_width: "%!q(float64= 65.5)" + fractional_precision: "%!q(float64=66)" + fractional_zero_padded: "%!q(float64=000065.5)" + fractional_alternate: "%!q(float64=65.5000)" + bad_dynamic_precision: "%!(BADPREC)\"x\"" + bad_index: "%!q(BADINDEX)" + bad_index_precision: "%!q(BADINDEX)" + bad_index_does_not_consume: "%!q(BADINDEX) \"x\"" + big_integer: "%!q(big.Int=9223372036854775808)" + boolean: "\"true\"" + null_value: "\"null\"" + array: "\"[1, \\\"x\\\"]\"" + set: "\"{true, 1, \\\"x\\\"}\"" + object: "\"{\\\"a\\\": 1, \\\"b\\\": 2}\"" diff --git a/tests/memory_limits.rs b/tests/memory_limits.rs index cbb9bec05..7c0dd758a 100644 --- a/tests/memory_limits.rs +++ b/tests/memory_limits.rs @@ -80,6 +80,12 @@ package limit large_array := json.unmarshal(data.limit.large_json) "#; +const SPRINTF_QUOTE_MODULE: &str = r#" +package limit + +quoted := sprintf("%q", [input]) +"#; + #[cfg(feature = "rvm")] const TIGHT_MEMORY_BUDGET_BYTES: u64 = 64 * 1024; @@ -213,6 +219,21 @@ fn interpreter_memory_limit_during_large_allocation() { assert_memory_limit_error(&err); } +#[test] +fn sprintf_quote_propagates_memory_limit_errors() { + let mut guard = LimitGuard::lock(); + let mut engine = new_engine_with_module(SPRINTF_QUOTE_MODULE); + engine.set_input(Value::String("\0".repeat(100_000).into())); + + // Quoting expands every NUL byte to four output bytes. Leave enough room + // to enter the builtin, but not enough to finish materializing the result. + guard.set_with_additional_budget(64 * 1024); + let err = engine + .eval_rule("data.limit.quoted".to_string()) + .expect_err("expected sprintf quoting to hit the memory limit"); + assert_memory_limit_error(&err); +} + #[cfg(feature = "jsonpatch")] #[test] fn json_patch_propagates_memory_limit_errors() { diff --git a/tests/rvm/rego/cases/sprintf.yaml b/tests/rvm/rego/cases/sprintf.yaml new file mode 100644 index 000000000..24d177367 --- /dev/null +++ b/tests/rvm/rego/cases/sprintf.yaml @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: sprintf_q_dynamic_indexed_and_value_conversions + data: {} + modules: + - | + package test + + result := { + "dynamic": sprintf("%*.*q", [8, 1, "éx"]), + "indexed": sprintf("%[3]*.[2]*[1]q", ["éx", 1, 8]), + "rune": sprintf("%q", [97]), + "array": sprintf("%q", [[1, "x"]]), + "float": sprintf("%8q", [65.5]), + "bad_index": sprintf("%[1]2q", ["x"]), + } + query: data.test.result + want_result: + dynamic: " \"é\"" + indexed: " \"é\"" + rune: "'a'" + array: "\"[1, \\\"x\\\"]\"" + float: "%!q(float64= 65.5)" + bad_index: "%!q(BADINDEX)"