snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
53
server/cmd/mmctl/printer/human/entry.go
Normal file
53
server/cmd/mmctl/printer/human/entry.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Time time.Time
|
||||
Level string
|
||||
Message string
|
||||
Caller string
|
||||
Fields []mlog.Field
|
||||
}
|
||||
|
||||
// Provide default string representation. Used by SimpleWriter
|
||||
func (f LogEntry) String() string {
|
||||
var sb strings.Builder
|
||||
if !f.Time.IsZero() {
|
||||
sb.WriteString(f.Time.Format(time.RFC3339Nano))
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
if f.Level != "" {
|
||||
sb.WriteString(f.Level)
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
if f.Caller != "" {
|
||||
sb.WriteString(f.Caller)
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
for _, field := range f.Fields {
|
||||
sb.WriteString(field.Key)
|
||||
sb.WriteRune('=')
|
||||
sb.WriteString(fmt.Sprint(field.Interface))
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
if f.Message != "" {
|
||||
// If the message is multiple lines, start the whole message on a new line
|
||||
if strings.ContainsRune(f.Message, '\n') {
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
sb.WriteString(f.Message)
|
||||
}
|
||||
|
||||
return printer.SanitizeForTerminal(sb.String())
|
||||
}
|
||||
95
server/cmd/mmctl/printer/human/entry_test.go
Normal file
95
server/cmd/mmctl/printer/human/entry_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogEntryString(t *testing.T) {
|
||||
baseTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
t.Run("plain entry renders as expected", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "info",
|
||||
Message: "hello world",
|
||||
Caller: "main.go:10",
|
||||
Fields: []mlog.Field{{Key: "key", Interface: "value"}},
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.Contains(t, got, "info")
|
||||
assert.Contains(t, got, "main.go:10")
|
||||
assert.Contains(t, got, "key=value")
|
||||
assert.Contains(t, got, "hello world")
|
||||
})
|
||||
|
||||
t.Run("strips ANSI CSI sequences from message", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "info",
|
||||
Message: "\x1b[31mRed Text\x1b[0m",
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.NotContains(t, got, "\x1b")
|
||||
assert.Contains(t, got, "Red Text")
|
||||
})
|
||||
|
||||
t.Run("strips OSC sequences (clipboard hijacking) from message", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "error",
|
||||
Message: "invalid token: Normal text\x1b]52;c;SGVsbG8gV29ybGQ=\x07more text",
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.NotContains(t, got, "\x1b")
|
||||
assert.NotContains(t, got, "\x07")
|
||||
assert.Contains(t, got, "Normal textmore text")
|
||||
})
|
||||
|
||||
t.Run("strips escape sequences injected via fields", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "info",
|
||||
Fields: []mlog.Field{{Key: "token", Interface: "\x1b]0;Fake Title\x07abc123"}},
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.NotContains(t, got, "\x1b")
|
||||
require.Contains(t, got, "token=")
|
||||
assert.Contains(t, got, "abc123")
|
||||
})
|
||||
|
||||
t.Run("strips other control characters", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "info",
|
||||
Message: "some\x00bytes\x7fhere",
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.NotContains(t, got, "\x00")
|
||||
assert.NotContains(t, got, "\x7f")
|
||||
assert.Contains(t, got, "somebyteshere")
|
||||
})
|
||||
|
||||
t.Run("preserves multi-line messages", func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Time: baseTime,
|
||||
Level: "info",
|
||||
Message: "line 1\nline 2",
|
||||
}
|
||||
|
||||
got := entry.String()
|
||||
assert.Contains(t, got, "line 1\nline 2")
|
||||
})
|
||||
}
|
||||
77
server/cmd/mmctl/printer/human/logrus_writer.go
Normal file
77
server/cmd/mmctl/printer/human/logrus_writer.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type LogrusWriter struct {
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
func (w *LogrusWriter) Write(e LogEntry) {
|
||||
if e.Level == "" {
|
||||
fmt.Fprintln(w.logger.Out, e.Message)
|
||||
return
|
||||
}
|
||||
|
||||
lvl, err := logrus.ParseLevel(e.Level)
|
||||
if err != nil {
|
||||
fmt.Fprintln(w.logger.Out, err)
|
||||
lvl = logrus.TraceLevel + 1 // will invoke Println
|
||||
}
|
||||
|
||||
logger := w.logger.WithTime(e.Time)
|
||||
|
||||
if e.Caller != "" {
|
||||
// logrus has a system of reporting the caller, but there's no easy way to override it
|
||||
logger = logger.WithField("caller", e.Caller)
|
||||
}
|
||||
|
||||
for _, field := range e.Fields {
|
||||
logger = logger.WithField(field.Key, field.Interface)
|
||||
}
|
||||
|
||||
switch lvl {
|
||||
case logrus.PanicLevel:
|
||||
// Prevent panic from causing us to exit
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
logger.Panic(e.Message)
|
||||
case logrus.FatalLevel:
|
||||
logger.Fatal(e.Message)
|
||||
case logrus.ErrorLevel:
|
||||
logger.Error(e.Message)
|
||||
case logrus.WarnLevel:
|
||||
logger.Warn(e.Message)
|
||||
case logrus.InfoLevel:
|
||||
logger.Info(e.Message)
|
||||
case logrus.DebugLevel:
|
||||
logger.Debug(e.Message)
|
||||
case logrus.TraceLevel:
|
||||
logger.Trace(e.Message)
|
||||
default:
|
||||
logger.Println(e.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func NewLogrusWriter(output io.Writer) *LogrusWriter {
|
||||
w := new(LogrusWriter)
|
||||
w.logger = logrus.New()
|
||||
w.logger.SetLevel(logrus.TraceLevel) // don't filter any logs
|
||||
w.logger.ExitFunc = func(int) {} // prevent Fatal from causing us to exit
|
||||
w.logger.SetReportCaller(false)
|
||||
w.logger.SetOutput(output)
|
||||
var tf logrus.TextFormatter
|
||||
tf.FullTimestamp = true
|
||||
tf.TimestampFormat = time.RFC3339Nano
|
||||
w.logger.SetFormatter(&tf)
|
||||
return w
|
||||
}
|
||||
180
server/cmd/mmctl/printer/human/parser.go
Normal file
180
server/cmd/mmctl/printer/human/parser.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
func ParseLogMessage(msg string) LogEntry {
|
||||
result, err := parseLogMessage(msg)
|
||||
if err != nil {
|
||||
// If failed to parse, just output a LogEntry where all fields are blank, but Message is the original string
|
||||
var result2 LogEntry
|
||||
result2.Message = msg
|
||||
return result2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseLogMessage(msg string) (result LogEntry, err error) {
|
||||
// Note: This implementation uses a custom json decoding loop.
|
||||
// The primary advantage of this versus decoding directly into a map is to
|
||||
// preserve the order of the fields. This can be simplified if we end up
|
||||
// having the formatter sort fields alphabetically (logrus does by default)
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(msg))
|
||||
|
||||
// look for an initial "{"
|
||||
token, err := dec.Token()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
d, ok := token.(json.Delim)
|
||||
if !ok || d != '{' {
|
||||
return result, fmt.Errorf("input is not a JSON object, found: %v", token)
|
||||
}
|
||||
|
||||
// read all key-value pairs
|
||||
for dec.More() {
|
||||
key, err2 := dec.Token()
|
||||
if err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
skey, ok2 := key.(string)
|
||||
if !ok2 {
|
||||
return result, errors.New("key is not a value string")
|
||||
}
|
||||
if !dec.More() {
|
||||
return result, errors.New("missing value pair")
|
||||
}
|
||||
|
||||
switch skey {
|
||||
case "ts":
|
||||
var ts json.Number
|
||||
if err2 := dec.Decode(&ts); err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
timeVal, err2 := numberToTime(ts)
|
||||
if err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
result.Time = timeVal
|
||||
|
||||
case "level":
|
||||
s, err2 := decodeAsString(dec)
|
||||
if err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
result.Level = s
|
||||
|
||||
case "msg":
|
||||
s, err2 := decodeAsString(dec)
|
||||
if err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
result.Message = s
|
||||
|
||||
case "caller":
|
||||
s, err2 := decodeAsString(dec)
|
||||
if err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
result.Caller = s
|
||||
|
||||
default:
|
||||
var p any
|
||||
if err2 := dec.Decode(&p); err2 != nil {
|
||||
return result, err2
|
||||
}
|
||||
var f mlog.Field
|
||||
f.Key = skey
|
||||
f.Interface = p
|
||||
result.Fields = append(result.Fields, f)
|
||||
}
|
||||
}
|
||||
|
||||
// read the "}"
|
||||
token, err = dec.Token()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
d, ok = token.(json.Delim)
|
||||
if !ok || d != '}' {
|
||||
return result, fmt.Errorf("failed to read '}', read: %v", token)
|
||||
}
|
||||
|
||||
// make sure nothing else trailing
|
||||
if token, err := dec.Token(); err != io.EOF {
|
||||
return result, err
|
||||
} else if token != nil {
|
||||
return result, errors.New("found trailing data")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Translate a number into a time
|
||||
func numberToTime(v json.Number) (time.Time, error) {
|
||||
// Using floating point math to extract the nanoseconds leads to a time that doesn't exactly match the input
|
||||
// Instead, parse out the components from the string representation
|
||||
|
||||
var t time.Time
|
||||
|
||||
// First make sure it is a number...
|
||||
flt, err := v.Float64()
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
s := v.String()
|
||||
|
||||
if strings.ContainsAny(s, "eE") {
|
||||
// input is in scientific notation. Convert to standard decimal notation
|
||||
s = strconv.FormatFloat(flt, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// extract the seconds and nanoseconds separately
|
||||
var nanos, sec int64
|
||||
|
||||
parts := strings.SplitN(s, ".", 2)
|
||||
sec, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
if len(parts) == 2 {
|
||||
nanosText := parts[1] + "000000000"
|
||||
nanosText = nanosText[:9]
|
||||
nanos, err = strconv.ParseInt(nanosText, 10, 64)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
|
||||
t = time.Unix(sec, nanos)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Decodes a value from JSON, coercing it to a string value as necessary
|
||||
func decodeAsString(dec *json.Decoder) (s string, err error) {
|
||||
var v any
|
||||
if err = dec.Decode(&v); err != nil {
|
||||
return s, err
|
||||
}
|
||||
var ok bool
|
||||
if s, ok = v.(string); ok {
|
||||
return s, err
|
||||
}
|
||||
s = fmt.Sprint(v)
|
||||
return s, err
|
||||
}
|
||||
23
server/cmd/mmctl/printer/human/process.go
Normal file
23
server/cmd/mmctl/printer/human/process.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
)
|
||||
|
||||
type LogWriter interface {
|
||||
Write(e LogEntry)
|
||||
}
|
||||
|
||||
// Read JSON logs from input and write formatted logs to the output
|
||||
func ProcessLogs(reader io.Reader, writer LogWriter) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
s := scanner.Text()
|
||||
e := ParseLogMessage(s)
|
||||
writer.Write(e)
|
||||
}
|
||||
}
|
||||
23
server/cmd/mmctl/printer/human/simple_writer.go
Normal file
23
server/cmd/mmctl/printer/human/simple_writer.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package human
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type SimpleWriter struct {
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func (w *SimpleWriter) Write(e LogEntry) {
|
||||
fmt.Fprintln(w.out, e)
|
||||
}
|
||||
|
||||
func NewSimpleWriter(out io.Writer) *SimpleWriter {
|
||||
w := new(SimpleWriter)
|
||||
w.out = out
|
||||
return w
|
||||
}
|
||||
57
server/cmd/mmctl/printer/keys.go
Normal file
57
server/cmd/mmctl/printer/keys.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
// These are the key that aliases
|
||||
const (
|
||||
ArrowLeft = rune(KeyCtrlB)
|
||||
ArrowRight = rune(KeyCtrlF)
|
||||
ArrowUp = rune(KeyCtrlP)
|
||||
ArrowDown = rune(KeyCtrlN)
|
||||
Space = ' '
|
||||
Enter = '\r'
|
||||
NewLine = '\n'
|
||||
Backspace = rune(KeyCtrlH)
|
||||
Backspace2 = rune(KeyDEL)
|
||||
)
|
||||
|
||||
// Key is the ascii codes of a keys
|
||||
type Key int16
|
||||
|
||||
// These are the control keys. Note that they overlap with other keys.
|
||||
const (
|
||||
KeyCtrlSpace Key = iota
|
||||
KeyCtrlA // KeySOH
|
||||
KeyCtrlB // KeySTX
|
||||
KeyCtrlC // KeyETX
|
||||
KeyCtrlD // KeyEOT
|
||||
KeyCtrlE // KeyENQ
|
||||
KeyCtrlF // KeyACK
|
||||
KeyCtrlG // KeyBEL
|
||||
KeyCtrlH // KeyBS
|
||||
KeyCtrlI // KeyTAB
|
||||
KeyCtrlJ // KeyLF
|
||||
KeyCtrlK // KeyVT
|
||||
KeyCtrlL // KeyFF
|
||||
KeyCtrlM // KeyCR
|
||||
KeyCtrlN // KeySO
|
||||
KeyCtrlO // KeySI
|
||||
KeyCtrlP // KeyDLE
|
||||
KeyCtrlQ // KeyDC1
|
||||
KeyCtrlR // KeyDC2
|
||||
KeyCtrlS // KeyDC3
|
||||
KeyCtrlT // KeyDC4
|
||||
KeyCtrlU // KeyNAK
|
||||
KeyCtrlV // KeySYN
|
||||
KeyCtrlW // KeyETB
|
||||
KeyCtrlX // KeyCAN
|
||||
KeyCtrlY // KeyEM
|
||||
KeyCtrlZ // KeySUB
|
||||
KeyESC // KeyESC
|
||||
KeyCtrlBackslash // KeyFS
|
||||
KeyCtrlRightSq // KeyGS
|
||||
KeyCtrlCarat // KeyRS
|
||||
KeyCtrlUnderscore // KeyUS
|
||||
KeyDEL = 0x7F
|
||||
)
|
||||
328
server/cmd/mmctl/printer/printer.go
Normal file
328
server/cmd/mmctl/printer/printer.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatPlain = "plain"
|
||||
FormatJSON = "json"
|
||||
)
|
||||
|
||||
type Printer struct { //nolint
|
||||
writer io.Writer
|
||||
eWriter io.Writer
|
||||
|
||||
Format string
|
||||
Single bool
|
||||
NoNewline bool
|
||||
templateFuncs template.FuncMap
|
||||
pager bool
|
||||
Quiet bool
|
||||
Lines []any
|
||||
ErrorLines []string
|
||||
|
||||
cmd *cobra.Command
|
||||
serverAddr string
|
||||
}
|
||||
|
||||
type printOpts struct {
|
||||
format string
|
||||
pagerPath string
|
||||
single bool
|
||||
usePager bool
|
||||
shortStat bool
|
||||
noNewline bool
|
||||
}
|
||||
|
||||
var printer Printer
|
||||
|
||||
func init() {
|
||||
printer.writer = os.Stdout
|
||||
printer.eWriter = os.Stderr
|
||||
printer.pager = true
|
||||
printer.templateFuncs = make(template.FuncMap)
|
||||
}
|
||||
|
||||
// SetFormat sets the format for the final output of the printer
|
||||
func SetFormat(t string) {
|
||||
printer.Format = t
|
||||
}
|
||||
|
||||
func SetCommand(cmd *cobra.Command) {
|
||||
printer.cmd = cmd
|
||||
}
|
||||
|
||||
func SetServerAddres(addr string) {
|
||||
printer.serverAddr = addr
|
||||
}
|
||||
|
||||
func OverrideEnablePager(enable bool) {
|
||||
printer.pager = enable
|
||||
}
|
||||
|
||||
// SetFormat sets the format for the final output of the printer
|
||||
func SetQuiet(q bool) {
|
||||
printer.Quiet = q
|
||||
}
|
||||
|
||||
// SetNoNewline prevents the addition of a newline at the end of the plain output.
|
||||
// This may be useful when you want to handle newlines yourself.
|
||||
func SetNoNewline(no bool) {
|
||||
printer.NoNewline = no
|
||||
}
|
||||
|
||||
func SetTemplateFunc(name string, f any) {
|
||||
printer.templateFuncs[name] = f
|
||||
}
|
||||
|
||||
// SetSingle sets the single flag on the printer. If this flag is set, the
|
||||
// printer will check the size of stored elements before printing, and
|
||||
// if there is only one, it will be printed on its own instead of
|
||||
// inside a list
|
||||
func SetSingle(single bool) {
|
||||
printer.Single = single
|
||||
}
|
||||
|
||||
// PrintT prints an element. Depending on the format, the element can be
|
||||
// formatted and printed as a structure or used to populate the
|
||||
// template
|
||||
func PrintT(templateString string, v any) {
|
||||
if printer.Quiet {
|
||||
return
|
||||
}
|
||||
switch printer.Format {
|
||||
case FormatPlain:
|
||||
tpl := template.Must(template.New("").Funcs(printer.templateFuncs).Parse(templateString))
|
||||
sb := &strings.Builder{}
|
||||
if err := tpl.Execute(sb, v); err != nil {
|
||||
PrintError("Can't print the message using the provided template: " + templateString)
|
||||
return
|
||||
}
|
||||
printer.Lines = append(printer.Lines, sb.String())
|
||||
case FormatJSON:
|
||||
printer.Lines = append(printer.Lines, v)
|
||||
}
|
||||
}
|
||||
|
||||
func PrintPreparedT(tpl *template.Template, v any) {
|
||||
if printer.Quiet {
|
||||
return
|
||||
}
|
||||
switch printer.Format {
|
||||
case FormatPlain:
|
||||
sb := &strings.Builder{}
|
||||
if err := tpl.Execute(sb, v); err != nil {
|
||||
PrintError("Can't print the message using the provided template: " + err.Error())
|
||||
return
|
||||
}
|
||||
printer.Lines = append(printer.Lines, sb.String())
|
||||
case FormatJSON:
|
||||
printer.Lines = append(printer.Lines, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Print an element. If the format requires a template, the element
|
||||
// will be printed as a structure with field names using the print
|
||||
// verb %+v
|
||||
func Print(v any) {
|
||||
PrintT("{{printf \"%+v\" .}}", v)
|
||||
}
|
||||
|
||||
// Flush writes the elements accumulated in the printer
|
||||
func Flush() error {
|
||||
if printer.Quiet {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := printOpts{
|
||||
format: printer.Format,
|
||||
single: printer.Single,
|
||||
noNewline: printer.NoNewline,
|
||||
}
|
||||
|
||||
cmd := printer.cmd
|
||||
if cmd != nil {
|
||||
shortStat, err := printer.cmd.Flags().GetBool("short-stat")
|
||||
if err == nil && printer.cmd.Name() == "list" && printer.cmd.Parent().Name() != "auth" {
|
||||
opts.shortStat = shortStat
|
||||
}
|
||||
}
|
||||
|
||||
b, err := printer.linesToBytes(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := lineCount(b)
|
||||
|
||||
isTTY := checkInteractiveTerminal() == nil
|
||||
var enablePager bool
|
||||
termHeight, err := termHeight(os.Stdout)
|
||||
if err == nil {
|
||||
enablePager = isTTY && (termHeight < lines) // calculate if we should enable paging
|
||||
}
|
||||
|
||||
pager := os.Getenv("PAGER")
|
||||
if enablePager {
|
||||
enablePager = pager != ""
|
||||
}
|
||||
|
||||
opts.usePager = enablePager && printer.pager
|
||||
opts.pagerPath = pager
|
||||
|
||||
err = printer.printBytes(b, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// after all, print errors
|
||||
printer.printErrors()
|
||||
|
||||
defer func() {
|
||||
printer.Lines = []any{}
|
||||
printer.ErrorLines = []string{}
|
||||
}()
|
||||
|
||||
if cmd == nil || cmd.Name() != "list" || printer.cmd.Parent().Name() == "auth" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// the command is a list command, we may want to
|
||||
// take care of the stat flags
|
||||
noStat, err := cmd.Flags().GetBool("no-stat")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// print stats
|
||||
switch {
|
||||
case noStat:
|
||||
// do nothing
|
||||
case !opts.shortStat:
|
||||
// should not go to pager
|
||||
if isTTY && !enablePager {
|
||||
fmt.Fprintf(printer.eWriter, "\n") // add a one line space before statistical data
|
||||
}
|
||||
fallthrough
|
||||
case len(printer.Lines) > 0:
|
||||
entity := cmd.Parent().Name()
|
||||
container := strings.TrimSuffix(printer.serverAddr, "api/v4")
|
||||
if container != "" {
|
||||
container = fmt.Sprintf(" on %s", container)
|
||||
}
|
||||
fmt.Fprintf(printer.eWriter, "There are %d %ss%s\n", len(printer.Lines), entity, container)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean resets the printer's accumulated lines
|
||||
func Clean() {
|
||||
printer.Lines = []any{}
|
||||
printer.ErrorLines = []string{}
|
||||
}
|
||||
|
||||
// GetLines returns the printer's accumulated lines
|
||||
func GetLines() []any {
|
||||
return printer.Lines
|
||||
}
|
||||
|
||||
// GetErrorLines returns the printer's accumulated error lines
|
||||
func GetErrorLines() []string {
|
||||
return printer.ErrorLines
|
||||
}
|
||||
|
||||
// PrintError prints to the stderr.
|
||||
func PrintError(msg string) {
|
||||
printer.ErrorLines = append(printer.ErrorLines, msg)
|
||||
}
|
||||
|
||||
// PrintWarning prints warning message to the error output, unlike Print and PrintError
|
||||
// functions, PrintWarning writes the output immediately instead of waiting command to finish.
|
||||
func PrintWarning(msg string) {
|
||||
if printer.Quiet {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(printer.eWriter, "%s\n", color.YellowString("WARNING: %s", msg))
|
||||
}
|
||||
|
||||
func (p Printer) linesToBytes(opts printOpts) (b []byte, err error) {
|
||||
if opts.shortStat {
|
||||
return
|
||||
}
|
||||
|
||||
newline := "\n"
|
||||
if opts.noNewline {
|
||||
newline = ""
|
||||
}
|
||||
|
||||
switch opts.format {
|
||||
case FormatPlain:
|
||||
var buf bytes.Buffer
|
||||
for i := range p.Lines {
|
||||
// Sanitize output to prevent terminal escape injection attacks
|
||||
// when writing user-controlled content to the terminal
|
||||
line := fmt.Sprintf("%s", p.Lines[i])
|
||||
fmt.Fprintf(&buf, "%s%s", SanitizeForTerminal(line), newline)
|
||||
}
|
||||
b = buf.Bytes()
|
||||
case FormatJSON:
|
||||
switch {
|
||||
case opts.single && len(p.Lines) == 0:
|
||||
return
|
||||
case opts.single && len(p.Lines) == 1:
|
||||
b, err = json.MarshalIndent(p.Lines[0], "", " ")
|
||||
default:
|
||||
b, err = json.MarshalIndent(p.Lines, "", " ")
|
||||
}
|
||||
b = append(b, '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p Printer) printBytes(b []byte, opts printOpts) error {
|
||||
if !opts.usePager {
|
||||
fmt.Fprintf(p.writer, "%s", b)
|
||||
return nil
|
||||
}
|
||||
|
||||
c := exec.Command(opts.pagerPath) // nolint:gosec
|
||||
|
||||
in, err := c.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create the stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
c.Stdout = p.writer
|
||||
c.Stderr = p.eWriter
|
||||
|
||||
go func() {
|
||||
defer in.Close()
|
||||
_, _ = io.Copy(in, bytes.NewReader(b))
|
||||
}()
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
return fmt.Errorf("could not start the pager: %w", err)
|
||||
}
|
||||
|
||||
return c.Wait()
|
||||
}
|
||||
|
||||
func (p Printer) printErrors() {
|
||||
for i := range printer.ErrorLines {
|
||||
fmt.Fprintln(printer.eWriter, printer.ErrorLines[i])
|
||||
}
|
||||
}
|
||||
61
server/cmd/mmctl/printer/printer_helpers.go
Normal file
61
server/cmd/mmctl/printer/printer_helpers.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Precompiled regex for terminal escape sequence sanitization.
|
||||
// References:
|
||||
// - XTerm Control Sequences: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
||||
var (
|
||||
// csiRegex matches ANSI CSI sequences (colors, cursor movement, etc).
|
||||
csiRegex = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]`)
|
||||
|
||||
// oscRegex matches OSC sequences (window title, clipboard, etc).
|
||||
oscRegex = regexp.MustCompile(`\x1b\]([^\x07\x1b]|\x1b[^\\])*(\x07|\x1b\\)`)
|
||||
|
||||
// dcsRegex matches DCS sequences (device control).
|
||||
dcsRegex = regexp.MustCompile(`\x1bP([^\x1b]|\x1b[^\\])*\x1b\\`)
|
||||
|
||||
// otherEscRegex matches other escape sequences (APC, PM, single-char).
|
||||
otherEscRegex = regexp.MustCompile(`\x1b[_^X]([^\x1b]|\x1b[^\\])*\x1b\\|\x1b[^\[\]P0-9]`)
|
||||
)
|
||||
|
||||
// SanitizeForTerminal strips ANSI escape sequences and control characters from
|
||||
// user-controlled content to prevent terminal injection attacks.
|
||||
// It preserves tabs, newlines, and carriage returns for readability.
|
||||
func SanitizeForTerminal(s string) string {
|
||||
// Remove ANSI CSI sequences
|
||||
result := csiRegex.ReplaceAllString(s, "")
|
||||
|
||||
// Remove OSC sequences
|
||||
result = oscRegex.ReplaceAllString(result, "")
|
||||
|
||||
// Remove DCS sequences
|
||||
result = dcsRegex.ReplaceAllString(result, "")
|
||||
|
||||
// Remove other escape sequences
|
||||
result = otherEscRegex.ReplaceAllString(result, "")
|
||||
|
||||
// Remove remaining control characters (0x00-0x1F, 0x7F) except tab, newline, carriage return.
|
||||
var cleaned strings.Builder
|
||||
cleaned.Grow(len(result))
|
||||
for _, r := range result {
|
||||
switch {
|
||||
case r == '\t' || r == '\n' || r == '\r':
|
||||
// Keep whitespace
|
||||
cleaned.WriteRune(r)
|
||||
case r < 0x20 || r == 0x7F:
|
||||
// Skip control characters
|
||||
continue
|
||||
default:
|
||||
cleaned.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned.String()
|
||||
}
|
||||
136
server/cmd/mmctl/printer/printer_helpers_test.go
Normal file
136
server/cmd/mmctl/printer/printer_helpers_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSanitizeForTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "Hello, World!",
|
||||
expected: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "preserves newlines and tabs",
|
||||
input: "Line 1\nLine 2\tTabbed",
|
||||
expected: "Line 1\nLine 2\tTabbed",
|
||||
},
|
||||
{
|
||||
name: "removes ANSI color codes",
|
||||
input: "\x1b[31mRed Text\x1b[0m",
|
||||
expected: "Red Text",
|
||||
},
|
||||
{
|
||||
name: "removes cursor movement sequences",
|
||||
input: "\x1b[2J\x1b[H\x1b[1;1HMalicious content",
|
||||
expected: "Malicious content",
|
||||
},
|
||||
{
|
||||
name: "removes OSC clipboard hijacking (BEL terminated)",
|
||||
input: "Normal text\x1b]52;c;SGVsbG8gV29ybGQ=\x07more text",
|
||||
expected: "Normal textmore text",
|
||||
},
|
||||
{
|
||||
name: "removes OSC clipboard hijacking (ST terminated)",
|
||||
input: "Normal text\x1b]52;c;SGVsbG8gV29ybGQ=\x1b\\more text",
|
||||
expected: "Normal textmore text",
|
||||
},
|
||||
{
|
||||
name: "removes OSC window title manipulation",
|
||||
input: "\x1b]0;Fake Terminal Title\x07Real content",
|
||||
expected: "Real content",
|
||||
},
|
||||
{
|
||||
name: "removes screen clearing sequences",
|
||||
input: "\x1b[2J\x1b[3JCleared screen",
|
||||
expected: "Cleared screen",
|
||||
},
|
||||
{
|
||||
name: "removes bold/underline formatting",
|
||||
input: "\x1b[1mBold\x1b[0m \x1b[4mUnderline\x1b[0m",
|
||||
expected: "Bold Underline",
|
||||
},
|
||||
{
|
||||
name: "removes multiple escape sequences",
|
||||
input: "\x1b[31m\x1b[1m\x1b[4mStyled\x1b[0m",
|
||||
expected: "Styled",
|
||||
},
|
||||
{
|
||||
name: "removes control characters (NUL, BEL, etc)",
|
||||
input: "Hello\x00World\x07Test\x08Back",
|
||||
expected: "HelloWorldTestBack",
|
||||
},
|
||||
{
|
||||
name: "removes DEL character",
|
||||
input: "Hello\x7fWorld",
|
||||
expected: "HelloWorld",
|
||||
},
|
||||
{
|
||||
name: "handles complex attack payload",
|
||||
input: "\x1b[2J\x1b[H\x1b]0;HACKED\x07\x1b[31mFake error!\x1b[0m\nEnter password: ",
|
||||
expected: "Fake error!\nEnter password: ",
|
||||
},
|
||||
{
|
||||
name: "removes DCS sequences",
|
||||
input: "Before\x1bPsome DCS content\x1b\\After",
|
||||
expected: "BeforeAfter",
|
||||
},
|
||||
{
|
||||
name: "handles empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "handles unicode text with escape sequences",
|
||||
input: "\x1b[31m你好世界\x1b[0m emoji: 🎉",
|
||||
expected: "你好世界 emoji: 🎉",
|
||||
},
|
||||
{
|
||||
name: "removes CSI with parameters",
|
||||
input: "\x1b[38;5;196mExtended color\x1b[0m",
|
||||
expected: "Extended color",
|
||||
},
|
||||
{
|
||||
name: "removes CSI with question mark",
|
||||
input: "\x1b[?25lHide cursor\x1b[?25h",
|
||||
expected: "Hide cursor",
|
||||
},
|
||||
{
|
||||
name: "preserves carriage return",
|
||||
input: "Line with\rcarriage return",
|
||||
expected: "Line with\rcarriage return",
|
||||
},
|
||||
{
|
||||
name: "removes APC sequences",
|
||||
input: "Before\x1b_APC content\x1b\\After",
|
||||
expected: "BeforeAfter",
|
||||
},
|
||||
{
|
||||
name: "removes nested escape attempts",
|
||||
input: "\x1b[31m\x1b]0;title\x07nested\x1b[0m",
|
||||
expected: "nested",
|
||||
},
|
||||
{
|
||||
name: "handles realistic malicious message",
|
||||
input: "Please run: \x1b[2J\x1b[Hsudo rm -rf /\x1b]52;c;cm0gLXJmIC8=\x07",
|
||||
expected: "Please run: sudo rm -rf /",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SanitizeForTerminal(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
161
server/cmd/mmctl/printer/printer_test.go
Normal file
161
server/cmd/mmctl/printer/printer_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type mockWriter []byte
|
||||
|
||||
func (w *mockWriter) Write(b []byte) (n int, err error) {
|
||||
*w = append(*w, b...)
|
||||
return len(*w) - len(b), nil
|
||||
}
|
||||
|
||||
func TestPrintT(t *testing.T) {
|
||||
w := bufio.NewWriter(&bytes.Buffer{})
|
||||
printer.writer = w
|
||||
printer.Format = FormatPlain
|
||||
|
||||
ts := struct {
|
||||
ID int
|
||||
}{
|
||||
ID: 123,
|
||||
}
|
||||
|
||||
t.Run("should execute template", func(t *testing.T) {
|
||||
tpl := `testing template {{.ID}}`
|
||||
PrintT(tpl, ts)
|
||||
assert.Len(t, GetLines(), 1)
|
||||
|
||||
assert.Equal(t, "testing template 123", printer.Lines[0])
|
||||
|
||||
_ = Flush()
|
||||
})
|
||||
|
||||
t.Run("should fail to execute, no method or field", func(t *testing.T) {
|
||||
Clean()
|
||||
tpl := `testing template {{.Name}}`
|
||||
PrintT(tpl, ts)
|
||||
assert.Len(t, GetErrorLines(), 1)
|
||||
|
||||
assert.Equal(t, "Can't print the message using the provided template: "+tpl, printer.ErrorLines[0])
|
||||
_ = Flush()
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintPreparedT(t *testing.T) {
|
||||
w := bufio.NewWriter(&bytes.Buffer{})
|
||||
printer.writer = w
|
||||
printer.Format = FormatPlain
|
||||
|
||||
ts := struct {
|
||||
ID int
|
||||
}{
|
||||
ID: 123,
|
||||
}
|
||||
|
||||
t.Run("should execute template", func(t *testing.T) {
|
||||
tpl := template.Must(template.New("").Parse(`testing template {{.ID}}`))
|
||||
PrintPreparedT(tpl, ts)
|
||||
assert.Len(t, GetLines(), 1)
|
||||
|
||||
assert.Equal(t, "testing template 123", printer.Lines[0])
|
||||
|
||||
_ = Flush()
|
||||
})
|
||||
|
||||
t.Run("should fail to execute, no method or field", func(t *testing.T) {
|
||||
Clean()
|
||||
tpl := template.Must(template.New("").Parse(`testing template {{.Name}}`))
|
||||
PrintPreparedT(tpl, ts)
|
||||
assert.Len(t, GetErrorLines(), 1)
|
||||
|
||||
assert.Contains(t, printer.ErrorLines[0], "Can't print the message using the provided template")
|
||||
_ = Flush()
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushJSON(t *testing.T) {
|
||||
printer.Format = FormatJSON
|
||||
|
||||
t.Run("should print a line in JSON format", func(t *testing.T) {
|
||||
mw := &mockWriter{}
|
||||
printer.writer = mw
|
||||
Clean()
|
||||
|
||||
Print("test string")
|
||||
assert.Len(t, GetLines(), 1)
|
||||
|
||||
_ = Flush()
|
||||
assert.Equal(t, "[\n \"test string\"\n]\n", string(*mw))
|
||||
assert.Empty(t, GetLines(), 0)
|
||||
})
|
||||
|
||||
t.Run("should print multi line in JSON format", func(t *testing.T) {
|
||||
mw := &mockWriter{}
|
||||
printer.writer = mw
|
||||
|
||||
Clean()
|
||||
Print("test string-1")
|
||||
Print("test string-2")
|
||||
assert.Len(t, GetLines(), 2)
|
||||
|
||||
_ = Flush()
|
||||
assert.Equal(t, "[\n \"test string-1\",\n \"test string-2\"\n]\n", string(*mw))
|
||||
assert.Empty(t, GetLines(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushPlain(t *testing.T) {
|
||||
printer.Format = FormatPlain
|
||||
|
||||
t.Run("should print a line in plain format", func(t *testing.T) {
|
||||
mw := &mockWriter{}
|
||||
printer.writer = mw
|
||||
Clean()
|
||||
|
||||
Print("test string")
|
||||
assert.Len(t, GetLines(), 1)
|
||||
|
||||
_ = Flush()
|
||||
assert.Equal(t, "test string\n", string(*mw))
|
||||
assert.Empty(t, GetLines(), 0)
|
||||
})
|
||||
|
||||
t.Run("should print multi line in plain format", func(t *testing.T) {
|
||||
mw := &mockWriter{}
|
||||
printer.writer = mw
|
||||
|
||||
Clean()
|
||||
Print("test string-1")
|
||||
Print("test string-2")
|
||||
assert.Len(t, GetLines(), 2)
|
||||
|
||||
_ = Flush()
|
||||
assert.Equal(t, "test string-1\ntest string-2\n", string(*mw))
|
||||
assert.Empty(t, GetLines(), 0)
|
||||
})
|
||||
|
||||
t.Run("should print multi line in plain format without a newline", func(t *testing.T) {
|
||||
mw := &mockWriter{}
|
||||
printer.writer = mw
|
||||
printer.NoNewline = true
|
||||
|
||||
Clean()
|
||||
Print("test string-1")
|
||||
Print("test string-2")
|
||||
assert.Len(t, GetLines(), 2)
|
||||
|
||||
_ = Flush()
|
||||
assert.Equal(t, "test string-1test string-2", string(*mw))
|
||||
assert.Empty(t, GetLines(), 0)
|
||||
})
|
||||
}
|
||||
38
server/cmd/mmctl/printer/util.go
Normal file
38
server/cmd/mmctl/printer/util.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func checkInteractiveTerminal() error {
|
||||
fileInfo, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (fileInfo.Mode() & os.ModeCharDevice) == 0 {
|
||||
return errors.New("this is not an interactive shell")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func termHeight(file *os.File) (int, error) {
|
||||
_, h, err := term.GetSize(int(file.Fd()))
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func lineCount(b []byte) int {
|
||||
return bytes.Count(b, []byte{'\n'})
|
||||
}
|
||||
Reference in New Issue
Block a user