This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 0c2bb0a28 fix(rmqctl): keep all table cells free of ASCII controls 
(#6396)
0c2bb0a28 is described below

commit 0c2bb0a282ed8e4659eea859bc16a733143a8713
Author: pilichoumao <[email protected]>
AuthorDate: Thu Oct 15 18:09:36 2026 +0800

    fix(rmqctl): keep all table cells free of ASCII controls (#6396)
---
 README.md                                     |  5 ++
 rmqctl/cmd/table_controls_test.go             | 95 +++++++++++++++++++++++++++
 rmqctl/internal/output/output.go              | 55 +++++++++++++---
 rmqctl/internal/output/table_controls_test.go | 89 +++++++++++++++++++++++++
 4 files changed, 235 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index 1b020eed0..ffc8daa90 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,11 @@ therefore serves the hosted agent, a developer typing 
`rmqctl topic list`, and a
 pointed at the output of `rmqctl mcp config`. See
 [docs/ai-agent-architecture.md](docs/ai-agent-architecture.md) for the design 
and its trade-offs.
 
+Standalone CLI commands support `--output table`, `json`, and `yaml`. Table 
cells
+show ASCII control characters as visible escapes (`\t`, `\n`, `\r`, or `\xNN`)
+so a context name, header, or returned value cannot split a row or send 
terminal
+control sequences. Stored values and JSON/YAML output keep the original 
content.
+
 - **Conversations persist server-side** and are browsable from the history 
drawer. Reloading or
   reconnecting replays exactly what the live stream rendered, because both 
paths reduce to the same
   block model.
diff --git a/rmqctl/cmd/table_controls_test.go 
b/rmqctl/cmd/table_controls_test.go
new file mode 100644
index 000000000..6d9a8fad7
--- /dev/null
+++ b/rmqctl/cmd/table_controls_test.go
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package cmd
+
+import (
+       "bytes"
+       "encoding/json"
+       "github.com/apache/rocketmq-dashboard/rmqctl/internal/config"
+       "gopkg.in/yaml.v3"
+       "strings"
+       "testing"
+)
+
+func TestConfigGetContextsControlCellsTest(t *testing.T) {
+       name := "dev\nforged\trow\x1b"
+       cfg := config.Config{CurrentContext: name, Contexts: 
map[string]config.Context{name: {Server: "https://studio.example.com";, 
Credential: config.CredentialRef{AccessKeyRef: "env:AK", SecretKeyRef: 
"env:SK"}}}}
+       path := newTestConfigPath(t)
+       if err := config.NewStore().Save(path, cfg); err != nil {
+               t.Fatal(err)
+       }
+       for _, format := range []string{"table", "json", "yaml"} {
+               t.Run(format, func(t *testing.T) {
+                       var out, diagnostic bytes.Buffer
+                       app := NewApp(&out, &diagnostic)
+                       app.Store.Getenv = emptyEnv
+                       if code := app.Execute([]string{"--config", path, 
"--output", format, "config", "get-contexts"}); code != 0 {
+                               t.Fatalf("exit=%d stderr=%q", code, 
diagnostic.String())
+                       }
+                       if format == "table" {
+                               if strings.Count(out.String(), "\n") != 2 || 
!strings.Contains(out.String(), `dev\nforged\trow\x1b`) {
+                                       t.Fatalf("unsafe config table: %q", 
out.String())
+                               }
+                       } else {
+                               var got config.Config
+                               var err error
+                               if format == "json" {
+                                       err = json.Unmarshal(out.Bytes(), &got)
+                               } else {
+                                       err = yaml.Unmarshal(out.Bytes(), &got)
+                               }
+                               if err != nil || got.CurrentContext != name || 
got.Contexts[name] != cfg.Contexts[name] {
+                                       t.Fatalf("structured config changed: 
%#v, err=%v", got, err)
+                               }
+                       }
+               })
+       }
+}
+
+func TestCatalogControlCellsWireOutputTest(t *testing.T) {
+       key := "detail\n\x1b"
+       value := "订单\vnote\fend\x1b"
+       payload := map[string]any{"items": []any{map[string]any{key: value}}}
+       for _, format := range []string{"table", "json", "yaml"} {
+               t.Run(format, func(t *testing.T) {
+                       stdout, stderr, code := executeCatalogResult(t, 
"rmq.topic.list", payload, "topic", "list", "--output", format)
+                       if code != 0 || stderr != "" {
+                               t.Fatalf("exit=%d stderr=%q", code, stderr)
+                       }
+                       if format == "table" {
+                               if want := 
"DETAIL\\n\\x1b\n订单\\x0bnote\\x0cend\\x1b\n"; stdout != want {
+                                       t.Fatalf("wire table=%q want=%q", 
stdout, want)
+                               }
+                       } else {
+                               var got map[string]any
+                               var err error
+                               if format == "json" {
+                                       err = json.Unmarshal([]byte(stdout), 
&got)
+                               } else {
+                                       err = yaml.Unmarshal([]byte(stdout), 
&got)
+                               }
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               items := got["items"].([]any)
+                               if items[0].(map[string]any)[key] != value {
+                                       t.Fatalf("wire value changed: %#v", got)
+                               }
+                       }
+               })
+       }
+}
diff --git a/rmqctl/internal/output/output.go b/rmqctl/internal/output/output.go
index 4860c933c..7f6fba71d 100644
--- a/rmqctl/internal/output/output.go
+++ b/rmqctl/internal/output/output.go
@@ -126,8 +126,8 @@ func ConfigTable(w io.Writer, cfg config.Config) error {
                }
                context := cfg.Contexts[name]
                fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\n",
-                       current, name, context.Server,
-                       context.Credential.AccessKeyRef, 
context.Credential.SecretKeyRef)
+                       current, escapeTableCell(name), 
escapeTableCell(context.Server),
+                       escapeTableCell(context.Credential.AccessKeyRef), 
escapeTableCell(context.Credential.SecretKeyRef))
        }
        return table.Flush()
 }
@@ -140,7 +140,7 @@ func ToolCallSummary(w io.Writer, result any) error {
        table := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
        fmt.Fprintln(table, "INSTANCE\tSTATUS\tCONFIRM TOKEN")
        fmt.Fprintf(table, "%s\t%s\t%s\n",
-               mutation.InstanceID, mutation.Status, mutation.ConfirmToken)
+               escapeTableCell(mutation.InstanceID), mutation.Status, 
escapeTableCell(mutation.ConfirmToken))
        if err := table.Flush(); err != nil {
                return err
        }
@@ -159,13 +159,13 @@ func Rows(w io.Writer, rows []map[string]any, columns 
[]Column) error {
        table := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
        headers := make([]string, 0, len(columns))
        for _, col := range columns {
-               headers = append(headers, col.Header)
+               headers = append(headers, escapeTableCell(col.Header))
        }
        fmt.Fprintln(table, strings.Join(headers, "\t"))
        for _, row := range rows {
                values := make([]string, 0, len(columns))
                for _, col := range columns {
-                       values = append(values, stringify(row[col.Key]))
+                       values = append(values, 
escapeTableCell(stringify(row[col.Key])))
                }
                fmt.Fprintln(table, strings.Join(values, "\t"))
        }
@@ -205,10 +205,7 @@ func stringify(value any) string {
                return ""
        }
        if text, ok := value.(string); ok {
-               // Table rows are tab-joined and newline-terminated, so a cell 
containing a
-               // raw tab would be read as a column delimiter and an embedded 
newline or CR
-               // would split or overwrite the row. Render the escapes instead.
-               return strings.NewReplacer("\t", "\\t", "\n", "\\n", "\r", 
"\\r").Replace(text)
+               return text
        }
        if f, ok := value.(float64); ok {
                return strconv.FormatFloat(f, 'f', -1, 64)
@@ -224,3 +221,43 @@ func stringify(value any) string {
        }
        return fmt.Sprint(value)
 }
+
+// escapeTableCell keeps each cell on one line and prevents ASCII controls from
+// being interpreted by tabwriter or the terminal. Backslashes and UTF-8 text
+// are preserved; this affects display only, not the stored or structured 
value.
+func escapeTableCell(text string) string {
+       first := -1
+       for i := 0; i < len(text); i++ {
+               if text[i] < 0x20 || text[i] == 0x7f {
+                       first = i
+                       break
+               }
+       }
+       if first < 0 {
+               return text
+       }
+       var escaped strings.Builder
+       escaped.Grow(len(text) + 4)
+       escaped.WriteString(text[:first])
+       const hex = "0123456789abcdef"
+       for i := first; i < len(text); i++ {
+               c := text[i]
+               switch c {
+               case '\t':
+                       escaped.WriteString(`\t`)
+               case '\n':
+                       escaped.WriteString(`\n`)
+               case '\r':
+                       escaped.WriteString(`\r`)
+               default:
+                       if c < 0x20 || c == 0x7f {
+                               escaped.WriteString(`\x`)
+                               escaped.WriteByte(hex[c>>4])
+                               escaped.WriteByte(hex[c&0x0f])
+                       } else {
+                               escaped.WriteByte(c)
+                       }
+               }
+       }
+       return escaped.String()
+}
diff --git a/rmqctl/internal/output/table_controls_test.go 
b/rmqctl/internal/output/table_controls_test.go
new file mode 100644
index 000000000..2761abf31
--- /dev/null
+++ b/rmqctl/internal/output/table_controls_test.go
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package output
+
+import (
+       "bytes"
+       "github.com/apache/rocketmq-dashboard/rmqctl/internal/config"
+       "strings"
+       "testing"
+)
+
+func TestTableCellControlsTest(t *testing.T) {
+       for _, tc := range []struct{ name, value, want string }{
+               {"vertical_tab", "a\vb", `a\x0bb`},
+               {"form_feed", "a\fb", `a\x0cb`},
+               {"escape", "a\x1bb", `a\x1bb`},
+               {"nul", "a\x00b", `a\x00b`},
+               {"bell_backspace_delete", "a\a\b\x7fb", `a\x07\x08\x7fb`},
+               {"unicode_and_literal_escape", `订单\path😀`, `订单\path😀`},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       var out bytes.Buffer
+                       if err := Rows(&out, []map[string]any{{"value": 
tc.value}}, []Column{{Header: "VALUE", Key: "value"}}); err != nil {
+                               t.Fatal(err)
+                       }
+                       if want := "VALUE\n" + tc.want + "\n"; out.String() != 
want {
+                               t.Fatalf("output = %q, want %q", out.String(), 
want)
+                       }
+               })
+       }
+}
+
+func TestTableHeadersControlsTest(t *testing.T) {
+       var out bytes.Buffer
+       if err := Rows(&out, []map[string]any{{"value": "normal"}}, 
[]Column{{Header: "FIELD\n\t\x1b", Key: "value"}}); err != nil {
+               t.Fatal(err)
+       }
+       if want := "FIELD\\n\\t\\x1b\nnormal\n"; out.String() != want {
+               t.Fatalf("output=%q, want %q", out.String(), want)
+       }
+}
+
+func TestConfigTableControlsTest(t *testing.T) {
+       name := "dev\nforged\trow"
+       cfg := config.Config{CurrentContext: name, Contexts: 
map[string]config.Context{name: {Server: "https://studio.example.com/\x1b";, 
Credential: config.CredentialRef{AccessKeyRef: "env:AK", SecretKeyRef: 
"env:SK"}}}}
+       var out bytes.Buffer
+       if err := ConfigTable(&out, cfg); err != nil {
+               t.Fatal(err)
+       }
+       if strings.Count(out.String(), "\n") != 2 || 
!strings.Contains(out.String(), `dev\nforged\trow`) || 
!strings.Contains(out.String(), `https://studio.example.com/\x1b`) {
+               t.Fatalf("unsafe table: %q", out.String())
+       }
+       if cfg.CurrentContext != name || cfg.Contexts[name].Server != 
"https://studio.example.com/\x1b"; {
+               t.Fatal("rendering modified config")
+       }
+}
+
+func TestMutationTableMetadataControlsTest(t *testing.T) {
+       value := map[string]any{"status": "PLANNED", "instanceId": 
"dev\nforged", "confirm_token": "token\f\x1b", "plan": 
map[string]any{"summary": "original\nmessage"}}
+       var out bytes.Buffer
+       if err := ToolCallSummary(&out, value); err != nil {
+               t.Fatal(err)
+       }
+       lines := strings.SplitN(out.String(), "\n\n", 2)
+       if len(lines) != 2 || strings.Count(lines[0], "\n") != 1 || 
!strings.Contains(lines[0], `dev\nforged`) || !strings.Contains(lines[0], 
`token\x0c\x1b`) {
+               t.Fatalf("unsafe metadata: %q", out.String())
+       }
+       var expected bytes.Buffer
+       if err := JSON(&expected, value["plan"]); err != nil {
+               t.Fatal(err)
+       }
+       if lines[1] != expected.String() {
+               t.Fatalf("plan changed: %q, want %q", lines[1], 
expected.String())
+       }
+}

Reply via email to