From 870651ac4cdfbc3311d2ac7b2c7ca872d298deb4 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Sun, 6 Sep 2026 14:20:16 +0300 Subject: [PATCH 1/6] feat(sprintf): support %q format verb for strings Go's `%q` verb (strconv.Quote) is used by OPA's sprintf, but regorus currently bails on it unconditionally. Add support for `Value::String` arguments only: wrap in double quotes, escape `"` and `\`, use the short escapes for the common control characters (\a \b \f \n \r \t \v), fall back to \xNN/\uNNNN/\UNNNNNNNN for other non-printable characters, and leave printable (including non-ASCII) characters untouched. Unlike json.marshal, %q does not HTML-escape < > &. %q on non-string values (numbers, bools, etc.) still bails as before - Go's %q on those produces different, single-quoted output that is out of scope for this change. Verified byte-for-byte against real OPA output (strconv.Quote semantics) for quotes, backslashes, control characters, DEL, non-ASCII printable text, and the < > & non-escaping case. Co-Authored-By: Claude Opus 5 --- src/builtins/strings.rs | 80 +++++++++++++++++++ .../cases/builtins/strings/sprintf.yaml | 53 +++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index fd3a2ee8b..fb2b230f8 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -232,6 +232,42 @@ fn apply_width(w: Width, s: String) -> String { } } +// Quote a string the same way Go's `strconv.Quote` (and therefore OPA's `%q` +// format verb) does: wrap in double quotes, escape `"` and `\`, use short +// escapes for the common control characters, `\xNN`/`\uNNNN`/`\UNNNNNNNN` for +// the rest of the non-printable characters, and leave every other (including +// non-ASCII) printable character untouched. Note that, unlike `json.marshal`, +// this does NOT HTML-escape `<`, `>` or `&`. +fn go_quote_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => 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 is_go_printable(c) => out.push(c), + c if (c as u32) <= 0x7f => out.push_str(&format!("\\x{:02x}", c as u32)), + c if (c as u32) <= 0xffff => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push_str(&format!("\\U{:08x}", c as u32)), + } + } + out.push('"'); + out +} + +// Approximates Go's `unicode.IsPrint`: printable characters are everything +// except control characters and non-ASCII-space whitespace/separators. +fn is_go_printable(c: char) -> bool { + !c.is_control() && (c == ' ' || !c.is_whitespace()) +} + fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> Result { let name = "sprintf"; ensure_args_count(span, name, params, args, 2)?; @@ -407,6 +443,8 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> bail!(args_span.error(&format!("number specified for format verb {verb}."))); } + ('q', Value::String(sv)) => s += &go_quote_string(sv.as_ref()), + ('+', _) if chars.next() == Some('v') => { bail!(args_span.error("Go-syntax fields names format verm %#v is not supported.")); } @@ -675,3 +713,45 @@ 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::*; + + // 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\""); + // Astral-plane printable characters are left as-is. + assert_eq!(go_quote_string("x\u{1F600}y"), "\"x\u{1F600}y\""); + } +} diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index 1933fbc7a..6cfd8b65e 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -77,4 +77,55 @@ 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"]) + 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\"" + empty: "\"\"" + + - note: "%q on a number still errors" + data: {} + modules: + - | + package test + + error_case := sprintf("%q", [42]) + query: data.test + error: "number specified for format verb q." + + - note: "%q on a non-string, non-number value still errors (only Value::String is supported)" + data: {} + modules: + - | + package test + + error_case := sprintf("%q", [true]) + query: data.test + error: "Go-syntax format verbs %#v. %q, %p and %T are not supported." \ No newline at end of file From 2c31535adaf8d5605de8491271d5d45e15f4f63b Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Mon, 7 Sep 2026 06:15:14 +0300 Subject: [PATCH 2/6] fix(sprintf): match Go string quoting semantics --- LICENSE | 5 +- src/builtins/strings.rs | 108 ++++++++-- src/builtins/strings/go_is_print.rs | 200 ++++++++++++++++++ .../cases/builtins/strings/sprintf.yaml | 33 ++- tests/memory_limits.rs | 21 ++ 5 files changed, 345 insertions(+), 22 deletions(-) create mode 100644 src/builtins/strings/go_is_print.rs 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 fb2b230f8..dcef02072 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -20,6 +20,8 @@ use crate::*; use anyhow::{bail, Result}; +mod go_is_print; + pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) { m.insert("concat", (concat, 2)); m.insert("contains", (contains, 2)); @@ -217,6 +219,7 @@ fn to_string(v: &Value, unescape: bool) -> String { } } +#[derive(Clone, Copy)] enum Width { None, LeadingZeros(usize), @@ -232,16 +235,29 @@ fn apply_width(w: Width, s: String) -> String { } } -// Quote a string the same way Go's `strconv.Quote` (and therefore OPA's `%q` -// format verb) does: wrap in double quotes, escape `"` and `\`, use short -// escapes for the common control characters, `\xNN`/`\uNNNN`/`\UNNNNNNNN` for -// the rest of the non-printable characters, and leave every other (including -// non-ASCII) printable character untouched. Note that, unlike `json.marshal`, -// this does NOT HTML-escape `<`, `>` or `&`. -fn go_quote_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); +const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + +// Append a string quoted like Go's `strconv.Quote` (and therefore OPA's `%q`). +// Precision truncates the input by Unicode scalar values before quoting, while +// width pads the quoted result by Unicode scalar values. +fn append_go_quoted(out: &mut String, input: &str, width: Width) -> Result<()> { + let input = match width { + Width::Decimals(precision) => truncate_chars(input, precision), + _ => input, + }; + + let (padding, padding_char) = match width { + Width::Cell(width) => (width.saturating_sub(go_quoted_len(input)), ' '), + Width::LeadingZeros(width) => (width.saturating_sub(go_quoted_len(input)), '0'), + Width::None | Width::Decimals(_) => (0, ' '), + }; + for _ in 0..padding { + out.push(padding_char); + enforce_limit()?; + } + out.push('"'); - for c in s.chars() { + for c in input.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), @@ -252,20 +268,45 @@ fn go_quote_string(s: &str) -> String { '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), '\u{000B}' => out.push_str("\\v"), - c if is_go_printable(c) => out.push(c), - c if (c as u32) <= 0x7f => out.push_str(&format!("\\x{:02x}", c as u32)), - c if (c as u32) <= 0xffff => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push_str(&format!("\\U{:08x}", c as u32)), + c if go_is_print::is_print(c) => 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('"'); - out + enforce_limit() +} + +fn truncate_chars(s: &str, count: usize) -> &str { + s.char_indices() + .nth(count) + .map_or(s, |(byte_index, _)| &s[..byte_index]) } -// Approximates Go's `unicode.IsPrint`: printable characters are everything -// except control characters and non-ASCII-space whitespace/separators. -fn is_go_printable(c: char) -> bool { - !c.is_control() && (c == ' ' || !c.is_whitespace()) +fn go_quoted_len(s: &str) -> usize { + s.chars().fold(2usize, |len, c| { + let escaped_len = match c { + '"' | '\\' | '\u{0007}' | '\u{0008}' | '\u{000C}' | '\n' | '\r' | '\t' | '\u{000B}' => { + 2 + } + c if go_is_print::is_print(c) => 1, + c if (c as u32) <= 0x7f => 4, + c if (c as u32) <= 0xffff => 6, + _ => 10, + }; + len.saturating_add(escaped_len) + }) +} + +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 { @@ -443,7 +484,7 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> bail!(args_span.error(&format!("number specified for format verb {verb}."))); } - ('q', Value::String(sv)) => s += &go_quote_string(sv.as_ref()), + ('q', Value::String(sv)) => append_go_quoted(&mut s, sv.as_ref(), width)?, ('+', _) if chars.next() == Some('v') => { bail!(args_span.error("Go-syntax fields names format verm %#v is not supported.")); @@ -718,6 +759,12 @@ fn upper(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> Re mod tests { use super::*; + fn go_quote_string(s: &str) -> String { + let mut out = String::new(); + append_go_quoted(&mut out, s, Width::None).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`. @@ -751,7 +798,30 @@ mod tests { // 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, width| { + let mut out = String::new(); + append_go_quoted(&mut out, input, width).expect("quoting must succeed"); + out + }; + + assert_eq!(quote("foo", Width::Cell(10)), " \"foo\""); + assert_eq!(quote("a", Width::LeadingZeros(5)), "00\"a\""); + assert_eq!(quote("abcdef", Width::Decimals(3)), "\"abc\""); + assert_eq!(quote("abc", Width::Decimals(0)), "\"\""); + assert_eq!(quote("\u{1F642}", Width::Cell(6)), " \"\u{1F642}\""); + assert_eq!(quote("\u{1F642}x", Width::Decimals(1)), "\"\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..342d2c569 --- /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,560 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/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index 6cfd8b65e..70899428d 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -96,6 +96,11 @@ cases: # %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"]) + private_use := sprintf("%q", ["\ue000"]) + noncharacter := sprintf("%q", ["\ufdd0"]) + unassigned := sprintf("%q", ["\u0378"]) empty := sprintf("%q", [""]) query: data.test want_result: @@ -108,8 +113,34 @@ cases: emoji: "\"emoji🙂\"" non_ascii: "\"юнікод\"" angle_and_amp: "\"a&c\"" + soft_hyphen: "\"\\u00ad\"" + zero_width_space: "\"\\u200b\"" + 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 on a number still errors" data: {} modules: @@ -128,4 +159,4 @@ cases: error_case := sprintf("%q", [true]) query: data.test - error: "Go-syntax format verbs %#v. %q, %p and %T are not supported." \ No newline at end of file + error: "Go-syntax format verbs %#v. %q, %p and %T are not supported." 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() { From 619a4439e11d34a16a9adddcee7674a40ac624ac Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Mon, 7 Sep 2026 06:43:56 +0300 Subject: [PATCH 3/6] feat(sprintf): support full q format specifications --- src/builtins/strings.rs | 352 +++++++++++++----- src/builtins/strings/sprintf_format.rs | 137 +++++++ .../cases/builtins/strings/sprintf.yaml | 56 ++- 3 files changed, 445 insertions(+), 100 deletions(-) create mode 100644 src/builtins/strings/sprintf_format.rs diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index dcef02072..c5d9181ef 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -21,6 +21,7 @@ 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)); @@ -227,6 +228,35 @@ 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, +} + +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, @@ -237,29 +267,60 @@ fn apply_width(w: Width, s: String) -> String { const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; -// Append a string quoted like Go's `strconv.Quote` (and therefore OPA's `%q`). -// Precision truncates the input by Unicode scalar values before quoting, while -// width pads the quoted result by Unicode scalar values. -fn append_go_quoted(out: &mut String, input: &str, width: Width) -> Result<()> { - let input = match width { - Width::Decimals(precision) => truncate_chars(input, precision), - _ => input, +// 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, padding_char) = match width { - Width::Cell(width) => (width.saturating_sub(go_quoted_len(input)), ' '), - Width::LeadingZeros(width) => (width.saturating_sub(go_quoted_len(input)), '0'), - Width::None | Width::Decimals(_) => (0, ' '), + let padding = spec.width.unwrap_or_default().saturating_sub(content_len); + let padding_char = if spec.flags.zero && !spec.flags.minus { + '0' + } else { + ' ' }; - for _ in 0..padding { - out.push(padding_char); + + if !spec.flags.minus { + append_padding(out, padding, padding_char)?; + } + + if raw { + out.push('`'); + out.push_str(input); enforce_limit()?; + out.push('`'); + enforce_limit()?; + } else { + append_go_quoted_body(out, input, spec.flags.plus, '"')?; } - out.push('"'); + 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 { - '"' => out.push_str("\\\""), + c if c == quote => { + out.push('\\'); + out.push(c); + } '\\' => out.push_str("\\\\"), '\u{0007}' => out.push_str("\\a"), '\u{0008}' => out.push_str("\\b"), @@ -268,30 +329,42 @@ fn append_go_quoted(out: &mut String, input: &str, width: Width) -> Result<()> { '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), '\u{000B}' => out.push_str("\\v"), - c if go_is_print::is_print(c) => out.push(c), + 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('"'); + 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) -> usize { +fn go_quoted_len(s: &str, ascii_only: bool, quote: char) -> usize { s.chars().fold(2usize, |len, c| { let escaped_len = match c { - '"' | '\\' | '\u{0007}' | '\u{0008}' | '\u{000C}' | '\n' | '\r' | '\t' | '\u{000B}' => { - 2 - } - c if go_is_print::is_print(c) => 1, + 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, @@ -300,6 +373,61 @@ fn go_quoted_len(s: &str) -> usize { }) } +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="); + if spec.flags.plus && value.is_sign_positive() { + out.push('+'); + } + out.push_str(&value.to_string()); + out.push(')'); + enforce_limit() + } + value => { + let value = to_string(value, false); + enforce_limit()?; + append_go_quoted(out, &value, spec) + } + } +} + fn append_hex_escape(out: &mut String, prefix: char, value: u32, digits: usize) { out.push('\\'); out.push(prefix); @@ -317,57 +445,44 @@ 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 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 args_idx >= args.len() { bail!(args_span @@ -375,13 +490,11 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> } let arg = &args[args_idx]; args_idx += 1; + 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()), @@ -480,25 +593,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}."))); } - ('q', Value::String(sv)) => append_go_quoted(&mut s, sv.as_ref(), width)?, - - ('+', _) 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.", @@ -761,7 +869,7 @@ mod tests { fn go_quote_string(s: &str) -> String { let mut out = String::new(); - append_go_quoted(&mut out, s, Width::None).expect("quoting must succeed"); + append_go_quoted(&mut out, s, FormatSpec::default()).expect("quoting must succeed"); out } @@ -811,17 +919,75 @@ mod tests { #[test] fn quote_string_applies_supported_width_and_precision() { - let quote = |input, width| { + let quote = |input, spec| { let mut out = String::new(); - append_go_quoted(&mut out, input, width).expect("quoting must succeed"); + append_go_quoted(&mut out, input, spec).expect("quoting must succeed"); out }; - assert_eq!(quote("foo", Width::Cell(10)), " \"foo\""); - assert_eq!(quote("a", Width::LeadingZeros(5)), "00\"a\""); - assert_eq!(quote("abcdef", Width::Decimals(3)), "\"abc\""); - assert_eq!(quote("abc", Width::Decimals(0)), "\"\""); - assert_eq!(quote("\u{1F642}", Width::Cell(6)), " \"\u{1F642}\""); - assert_eq!(quote("\u{1F642}x", Width::Decimals(1)), "\"\u{1F642}\""); + 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, + } + ), + "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/sprintf_format.rs b/src/builtins/strings/sprintf_format.rs new file mode 100644 index 000000000..5c2f74da6 --- /dev/null +++ b/src/builtins/strings/sprintf_format.rs @@ -0,0 +1,137 @@ +// 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; + } + + if let Some(index) = parse_index(bytes, &mut cursor)? { + *args_idx = index; + *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.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 70899428d..e7f2d0e03 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -141,22 +141,64 @@ cases: unicode_width: " \"🙂\"" unicode_precision: "\"🙂\"" - - note: "%q on a number still errors" + - note: "%q full string format specification" data: {} modules: - | package test - error_case := sprintf("%q", [42]) + 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 - error: "number specified for format verb q." - - - note: "%q on a non-string, non-number value still errors (only Value::String is supported)" + 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 - error_case := sprintf("%q", [true]) + 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]) + 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 - error: "Go-syntax format verbs %#v. %q, %p and %T are not supported." + want_result: + rune: "'a'" + control_rune: "'\\x00'" + quote_rune: "'\\''" + invalid_rune: "'�'" + ascii_rune: "'\\u00e9'" + padded_rune: " 'a'" + fractional: "%!q(float64=65.5)" + big_integer: "%!q(big.Int=9223372036854775808)" + boolean: "\"true\"" + null_value: "\"null\"" + array: "\"[1, \\\"x\\\"]\"" + set: "\"{true, 1, \\\"x\\\"}\"" + object: "\"{\\\"a\\\": 1, \\\"b\\\": 2}\"" From 3568dac42e1ddbe47a19526590d48d04ecbcafc3 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Thu, 10 Sep 2026 16:19:17 +0300 Subject: [PATCH 4/6] fix(sprintf): address q format review feedback --- src/builtins/strings.rs | 141 ++++++++++++++++-- src/builtins/strings/go_is_print.rs | 2 +- src/builtins/strings/sprintf_format.rs | 8 + .../cases/builtins/strings/sprintf.yaml | 10 ++ tests/rvm/rego/cases/sprintf.yaml | 24 +++ 5 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 tests/rvm/rego/cases/sprintf.yaml diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index c5d9181ef..1615b0f63 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -242,6 +242,7 @@ struct FormatSpec { flags: FormatFlags, width: Option, precision: Option, + bad_precision: bool, } impl FormatSpec { @@ -294,8 +295,10 @@ fn append_go_quoted(out: &mut String, input: &str, spec: FormatSpec) -> Result<( if raw { out.push('`'); - out.push_str(input); - enforce_limit()?; + for c in input.chars() { + out.push(c); + enforce_limit()?; + } out.push('`'); enforce_limit()?; } else { @@ -413,19 +416,129 @@ fn append_go_quoted_value(out: &mut String, value: &Value, spec: FormatSpec) -> } Value::Number(Number::Float(value)) => { out.push_str("%!q(float64="); - if spec.flags.plus && value.is_sign_positive() { - out.push('+'); - } - out.push_str(&value.to_string()); + append_go_float_value(out, value, spec)?; out.push(')'); enforce_limit() } value => { - let value = to_string(value, false); - enforce_limit()?; - append_go_quoted(out, &value, spec) + 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) { @@ -484,12 +597,21 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> )); } + 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 .error(format!("no argument specified for format verb {args_idx}").as_str())); } 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. @@ -945,6 +1067,7 @@ mod tests { }, width: Some(5), precision: None, + ..FormatSpec::default() } ), "00\"a\"" diff --git a/src/builtins/strings/go_is_print.rs b/src/builtins/strings/go_is_print.rs index 342d2c569..b3945eb13 100644 --- a/src/builtins/strings/go_is_print.rs +++ b/src/builtins/strings/go_is_print.rs @@ -4,7 +4,7 @@ // 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,560 bytes. +// 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, diff --git a/src/builtins/strings/sprintf_format.rs b/src/builtins/strings/sprintf_format.rs index 5c2f74da6..a1e0c27c2 100644 --- a/src/builtins/strings/sprintf_format.rs +++ b/src/builtins/strings/sprintf_format.rs @@ -89,6 +89,9 @@ pub(super) fn parse( 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'.')) { + bail!(format_span.error("invalid sprintf argument index")); + } } if bytes.get(cursor) == Some(&b'*') { @@ -110,12 +113,17 @@ pub(super) fn parse( 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'.')) { + bail!(format_span.error("invalid sprintf argument index")); + } } 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()); diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index e7f2d0e03..95ac5416d 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -181,6 +181,11 @@ cases: 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"]) big_integer := sprintf("%q", [9223372036854775808]) boolean := sprintf("%q", [true]) null_value := sprintf("%q", [null]) @@ -196,6 +201,11 @@ cases: 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\"" big_integer: "%!q(big.Int=9223372036854775808)" boolean: "\"true\"" null_value: "\"null\"" diff --git a/tests/rvm/rego/cases/sprintf.yaml b/tests/rvm/rego/cases/sprintf.yaml new file mode 100644 index 000000000..9b76af24b --- /dev/null +++ b/tests/rvm/rego/cases/sprintf.yaml @@ -0,0 +1,24 @@ +# 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]), + } + query: data.test.result + want_result: + dynamic: " \"é\"" + indexed: " \"é\"" + rune: "'a'" + array: "\"[1, \\\"x\\\"]\"" + float: "%!q(float64= 65.5)" From 8fb8329f6de66aec5549a6d1f17454c50760a1a4 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Fri, 11 Sep 2026 06:20:59 +0300 Subject: [PATCH 5/6] fix(sprintf): emit Go bad index diagnostics --- src/builtins/strings.rs | 9 +++++++++ src/builtins/strings/sprintf_format.rs | 10 ++++++---- tests/interpreter/cases/builtins/strings/sprintf.yaml | 6 ++++++ tests/rvm/rego/cases/sprintf.yaml | 2 ++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index 1615b0f63..2c0b00e5b 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -243,6 +243,7 @@ struct FormatSpec { width: Option, precision: Option, bad_precision: bool, + bad_index: bool, } impl FormatSpec { @@ -589,6 +590,14 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> 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) { diff --git a/src/builtins/strings/sprintf_format.rs b/src/builtins/strings/sprintf_format.rs index a1e0c27c2..9f4773cf1 100644 --- a/src/builtins/strings/sprintf_format.rs +++ b/src/builtins/strings/sprintf_format.rs @@ -86,11 +86,16 @@ pub(super) fn parse( 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'.')) { - bail!(format_span.error("invalid sprintf argument index")); + 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; } } @@ -113,9 +118,6 @@ pub(super) fn parse( 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'.')) { - bail!(format_span.error("invalid sprintf argument index")); - } } if bytes.get(cursor) == Some(&b'*') { cursor += 1; diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index 95ac5416d..11ada4aa5 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -186,6 +186,9 @@ cases: 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]) @@ -206,6 +209,9 @@ cases: 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\"" diff --git a/tests/rvm/rego/cases/sprintf.yaml b/tests/rvm/rego/cases/sprintf.yaml index 9b76af24b..24d177367 100644 --- a/tests/rvm/rego/cases/sprintf.yaml +++ b/tests/rvm/rego/cases/sprintf.yaml @@ -14,6 +14,7 @@ cases: "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: @@ -22,3 +23,4 @@ cases: rune: "'a'" array: "\"[1, \\\"x\\\"]\"" float: "%!q(float64= 65.5)" + bad_index: "%!q(BADINDEX)" From 60b38bbe501e009d9aaba2b879a56a5d49eee865 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Fri, 11 Sep 2026 06:42:03 +0300 Subject: [PATCH 6/6] test(sprintf): cover Unicode separators in q verb --- tests/interpreter/cases/builtins/strings/sprintf.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml index 11ada4aa5..75884bd22 100644 --- a/tests/interpreter/cases/builtins/strings/sprintf.yaml +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -98,6 +98,8 @@ cases: 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"]) @@ -115,6 +117,8 @@ cases: 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\""