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())
+ }
+}