From 408d9b5e835c960b3e2a2a7a98f292081a705597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8C=B2=20Harry=20=F0=9F=8C=8A=20John=20=F0=9F=8F=94?= Date: Tue, 26 May 2026 11:19:48 -0700 Subject: [PATCH] fix: re-escape backslashes and quotes in Display/prettify output --- src/label/matcher.rs | 6 +++--- src/label/mod.rs | 3 ++- src/parser/ast.rs | 42 +++++++++++++++++++++++++++++++++++++++-- src/util/mod.rs | 2 +- src/util/string.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/label/matcher.rs b/src/label/matcher.rs index 6823248..3174a53 100644 --- a/src/label/matcher.rs +++ b/src/label/matcher.rs @@ -19,7 +19,7 @@ use regex::Regex; use crate::parser::lex::is_label; use crate::parser::token::{token_display, TokenId, T_EQL, T_EQL_REGEX, T_NEQ, T_NEQ_REGEX}; -use crate::util::join_vector; +use crate::util::{escape_string, join_vector}; const LABEL_METRIC_NAME: &str = "__name__"; @@ -152,9 +152,9 @@ impl fmt::Display for Matcher { let name = if is_label(&self.name) { self.name.clone() } else { - format!("\"{}\"", self.name) + format!("\"{}\"", escape_string(&self.name)) }; - write!(f, "{}{}\"{}\"", name, self.op, self.value) + write!(f, "{}{}\"{}\"", name, self.op, escape_string(&self.value)) } } diff --git a/src/label/mod.rs b/src/label/mod.rs index c05ff3e..4e1ac11 100644 --- a/src/label/mod.rs +++ b/src/label/mod.rs @@ -18,6 +18,7 @@ use std::collections::HashSet; use std::fmt; use crate::parser::lex::is_label; +use crate::util::escape_string; mod matcher; pub use matcher::{MatchOp, Matcher, Matchers}; @@ -78,7 +79,7 @@ impl fmt::Display for Labels { if is_label(label) { label.clone() } else { - format!("\"{}\"", label) + format!("\"{}\"", escape_string(label)) } }) .collect(); diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 52bddb5..5c3b1f7 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -19,7 +19,7 @@ use crate::parser::token::{ use crate::parser::token::{Token, TokenId, TokenType}; use crate::parser::value::ValueType; use crate::parser::{indent, Function, FunctionArgs, Prettier, MAX_CHARACTERS_PER_LINE}; -use crate::util::display_duration; +use crate::util::{display_duration, escape_string}; use chrono::{DateTime, Utc}; use std::fmt::{self, Write}; use std::ops::Neg; @@ -870,7 +870,7 @@ pub struct StringLiteral { impl fmt::Display for StringLiteral { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "\"{}\"", self.val) + write!(f, "\"{}\"", escape_string(&self.val)) } } @@ -2973,4 +2973,42 @@ or assert_eq!(prettified, expected); } } + + #[test] + fn test_prettify_escape_roundtrip() { + // Queries with backslash escapes must survive parse → prettify → re-parse + let cases = vec![ + // Escaped dot in regex matcher + ( + r#"{__name__="up",service=~"flagd\\.evaluation\\.v1\\.Service"}"#, + r#"{__name__="up",service=~"flagd\\.evaluation\\.v1\\.Service"}"#, + ), + // Escaped pipe + ( + r#"{__name__="up",tag=~"a\\|b"}"#, + r#"{__name__="up",tag=~"a\\|b"}"#, + ), + // Literal backslash in value + (r#"{path="C:\\\\Windows"}"#, r#"{path="C:\\\\Windows"}"#), + // Embedded double quote + (r#"{msg="say \"hello\""}"#, r#"{msg="say \"hello\""}"#), + ]; + + for (input, expected) in &cases { + let parsed = crate::parser::parse(input).unwrap(); + let prettified = parsed.prettify(); + assert_eq!( + &prettified, expected, + "prettify mismatch for input: {input}" + ); + + // Roundtrip: re-parsing the prettified output must succeed and produce the same result + let reparsed = crate::parser::parse(&prettified).unwrap(); + assert_eq!( + parsed.prettify(), + reparsed.prettify(), + "roundtrip failed for input: {input}" + ); + } + } } diff --git a/src/util/mod.rs b/src/util/mod.rs index e59c4a1..46e3209 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -21,7 +21,7 @@ pub mod visitor; pub use duration::{display_duration, parse_duration}; pub use number::parse_str_radix; -pub use string::unquote_string; +pub use string::{escape_string, unquote_string}; pub use visitor::{walk_expr, ExprVisitor}; pub(crate) fn join_vector(v: &[T], sep: &str, sort: bool) -> String { diff --git a/src/util/string.rs b/src/util/string.rs index aa389c8..b8d96d0 100644 --- a/src/util/string.rs +++ b/src/util/string.rs @@ -14,6 +14,23 @@ //! Internal utilities for strings. +/// Escapes a string value for embedding in a PromQL double-quoted string literal. +/// This is the inverse of `unquote_string` — it re-escapes backslashes and double quotes. +pub fn escape_string(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + _ => result.push(c), + } + } + result +} + /// This function is modified from original go version /// https://github.com/prometheus/prometheus/blob/v3.8.0/util/strutil/quote.go pub fn unquote_string(s: &str) -> Result { @@ -346,4 +363,32 @@ mod tests { assert!(unquote_string("`hello`world`").is_err()); assert!(unquote_string("``hello`").is_err()); } + + #[test] + fn test_escape_string() { + assert_eq!(escape_string("hello"), "hello"); + assert_eq!(escape_string(r#"say "hi""#), r#"say \"hi\""#); + assert_eq!(escape_string("back\\slash"), "back\\\\slash"); + assert_eq!(escape_string("new\nline"), "new\\nline"); + assert_eq!(escape_string("tab\there"), "tab\\there"); + assert_eq!(escape_string("cr\rhere"), "cr\\rhere"); + } + + #[test] + fn test_escape_unquote_roundtrip() { + // escape_string should produce output that unquote_string can reverse + let values = vec![ + "hello", + "flagd\\.eval", + "a\\|b", + "C:\\\\Windows", + "say \"hi\"", + ]; + for val in values { + let escaped = escape_string(val); + let quoted = format!("\"{}\"", escaped); + let unquoted = unquote_string("ed).unwrap(); + assert_eq!(unquoted, val, "roundtrip failed for: {val:?}"); + } + } }