diff --git a/defs/bytes_counter.go b/defs/bytes_counter.go index 7111b3c..4d17de1 100644 --- a/defs/bytes_counter.go +++ b/defs/bytes_counter.go @@ -1,55 +1,34 @@ package defs import ( - "bytes" "fmt" "io" "math/rand/v2" - "sync" "sync/atomic" "time" ) -// BytesCounter implements io.Reader and io.Writer interface, for counting bytes being read/written in HTTP requests +// BytesCounter implements the io.Writer interface, for counting bytes being read/written in HTTP requests. +// It is only ever plugged into an io.TeeReader, so the transfer itself is driven by the wrapped reader. type BytesCounter struct { start time.Time - pos int - total uint64 + total atomic.Uint64 payload []byte - reader io.ReadSeeker mebi bool uploadSize int - - lock *sync.Mutex } func NewCounter() *BytesCounter { - return &BytesCounter{ - lock: &sync.Mutex{}, - } + return &BytesCounter{} } // Write implements io.Writer func (c *BytesCounter) Write(p []byte) (int, error) { n := len(p) - atomic.AddUint64(&c.total, uint64(n)) + c.total.Add(uint64(n)) return n, nil } -// Read implements io.Reader -func (c *BytesCounter) Read(p []byte) (int, error) { - c.lock.Lock() - n, err := c.reader.Read(p) - c.total += uint64(n) - c.pos += n - if c.pos == c.uploadSize { - c.resetReader() - } - c.lock.Unlock() - - return n, err -} - // SetBase sets the base for dividing bytes into megabyte or mebibyte func (c *BytesCounter) SetMebi(mebi bool) { c.mebi = mebi @@ -62,7 +41,7 @@ func (c *BytesCounter) SetUploadSize(uploadSize int) { // AvgBytes returns the average bytes/second func (c *BytesCounter) AvgBytes() float64 { - return float64(c.total) / time.Since(c.start).Seconds() + return float64(c.total.Load()) / time.Since(c.start).Seconds() } // AvgMbps returns the average mbits/second @@ -99,17 +78,9 @@ func (c *BytesCounter) Payload() []byte { return c.payload } -// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field, and sets the `reader` field to -// read from it +// GenerateBlob generates a random byte array of `uploadSize` in the `payload` field func (c *BytesCounter) GenerateBlob() { c.payload = getRandomData(c.uploadSize) - c.reader = bytes.NewReader(c.payload) -} - -// resetReader resets the `reader` field to 0 position -func (c *BytesCounter) resetReader() (int64, error) { - c.pos = 0 - return c.reader.Seek(0, 0) } // Start will set the `start` field to current time @@ -119,12 +90,12 @@ func (c *BytesCounter) Start() { // Total returns the total bytes read/written func (c *BytesCounter) Total() uint64 { - return atomic.LoadUint64(&c.total) + return c.total.Load() } // CurrentSpeed returns the current bytes/second func (c *BytesCounter) CurrentSpeed() float64 { - return float64(c.total) / time.Since(c.start).Seconds() + return float64(c.total.Load()) / time.Since(c.start).Seconds() } // SeekWrapper is a wrapper around io.Reader to give it a noop io.Seeker interface diff --git a/defs/bytes_counter_test.go b/defs/bytes_counter_test.go new file mode 100644 index 0000000..6326842 --- /dev/null +++ b/defs/bytes_counter_test.go @@ -0,0 +1,74 @@ +package defs + +import ( + "sync" + "testing" +) + +// TestBytesCounterConcurrentAccess exercises the pattern the counter is used +// in: several transfer goroutines writing while the progress spinner polls the +// averages. It is the regression test for the mixed atomic/non-atomic access +// that used to make this racy, so it is only meaningful under -race. +func TestBytesCounterConcurrentAccess(t *testing.T) { + const ( + writers = 8 + writes = 2000 + chunkSize = 16 + pollers = 4 + wantTotal = uint64(writers * writes * chunkSize) + pollBudget = 1 << 20 + ) + + c := NewCounter() + c.Start() + + stop := make(chan struct{}) + + var polling sync.WaitGroup + for i := 0; i < pollers; i++ { + polling.Add(1) + go func() { + defer polling.Done() + for n := 0; n < pollBudget; n++ { + select { + case <-stop: + return + default: + } + _ = c.AvgBytes() + _ = c.AvgMbps() + _ = c.AvgHumanize() + _ = c.CurrentSpeed() + _ = c.Total() + } + }() + } + + var writing sync.WaitGroup + for i := 0; i < writers; i++ { + writing.Add(1) + go func() { + defer writing.Done() + chunk := make([]byte, chunkSize) + for j := 0; j < writes; j++ { + n, err := c.Write(chunk) + if err != nil { + t.Errorf("Write returned error: %v", err) + return + } + if n != chunkSize { + t.Errorf("Write returned %d, want %d", n, chunkSize) + return + } + } + }() + } + + writing.Wait() + close(stop) + polling.Wait() + + if got := c.Total(); got != wantTotal { + t.Errorf("Total() = %d, want %d (lost updates indicate a broken counter)", got, wantTotal) + } +} diff --git a/defs/server.go b/defs/server.go index b5d195f..c6f308c 100644 --- a/defs/server.go +++ b/defs/server.go @@ -11,8 +11,10 @@ import ( "math" "net/http" "net/url" + "os" "path" "strconv" + "sync" "time" "github.com/briandowns/spinner" @@ -61,7 +63,9 @@ func (s *Server) IsUp() bool { defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil || len(b) > 0 { - output.WriteDebug("Failed when parsing get IP result: %s\n", b) + // %q rather than Sanitize: this is a raw response body where newlines are + // legitimate, and quoting escapes control chars without losing them + output.WriteDebug("Failed when parsing get IP result: %q\n", b) return false } // only return online if the ping URL returns nothing and 200 @@ -76,7 +80,7 @@ func (s *Server) ICMPPingAndJitter(count int, srcIp, network string) (float64, f }() if s.NoICMP { - output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", s.Name) + output.WriteDebug("Skipping ICMP for server %s, will use HTTP ping\n", output.Sanitize(s.Name)) return s.PingAndJitter(count + 2) } @@ -126,7 +130,7 @@ func (s *Server) ICMPPingAndJitter(count int, srcIp, network string) (float64, f if len(stats.Rtts) == 0 { s.NoICMP = true - output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", s.Name, u.Hostname()) + output.WriteDebug("No ICMP pings returned for server %s (%s), trying TCP ping\n", output.Sanitize(s.Name), output.Sanitize(u.Hostname())) return s.PingAndJitter(count + 2) } @@ -226,29 +230,43 @@ func (s *Server) Download(silent bool, useBytes, useMebi bool, requests int, chu downloadDone := make(chan struct{}, requests) + var wg sync.WaitGroup + doDownload := func() { + defer wg.Done() + reqClone := req.Clone(ctx) resp, err := http.DefaultClient.Do(reqClone) if err != nil { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { output.WriteDebug("Failed when making HTTP request: %s\n", err) } - } else { - defer resp.Body.Close() + return + } + defer resp.Body.Close() - if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - output.WriteDebug("Failed when reading HTTP response: %s\n", err) - } + if _, err = io.Copy(io.Discard, io.TeeReader(resp.Body, counter)); err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + output.WriteDebug("Failed when reading HTTP response: %s\n", err) } + } - downloadDone <- struct{}{} + // let the main loop start a replacement request, but never block on it + // once the test is over, otherwise this goroutine is leaked + select { + case downloadDone <- struct{}{}: + case <-ctx.Done(): } } + spawnDownload := func() { + wg.Add(1) + go doDownload() + } + counter.Start() if !silent { - pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond) + pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) pb.Prefix = "Downloading... " pb.PostUpdate = func(s *spinner.Spinner) { if useBytes { @@ -259,18 +277,21 @@ func (s *Server) Download(silent bool, useBytes, useMebi bool, requests int, chu } pb.Start() + // print the rate ourselves instead of via pb.FinalMSG: the spinner only + // prints it when it was actually running, which it isn't when stderr is + // not a terminal defer func() { + pb.Stop() if useBytes { - pb.FinalMSG = fmt.Sprintf("Download rate:\t%s\n", counter.AvgHumanize()) + output.WriteUI("Download rate:\t%s\n", counter.AvgHumanize()) } else { - pb.FinalMSG = fmt.Sprintf("Download rate:\t%.2f Mbps\n", counter.AvgMbps()) + output.WriteUI("Download rate:\t%.2f Mbps\n", counter.AvgMbps()) } - pb.Stop() }() } for i := 0; i < requests; i++ { - go doDownload() + spawnDownload() time.Sleep(200 * time.Millisecond) } timeout := time.After(duration) @@ -281,10 +302,14 @@ Loop: cancel() break Loop case <-downloadDone: - go doDownload() + spawnDownload() } } + // let the cancelled requests unwind before reading the counter, so the + // result doesn't change under us while it's being reported + wg.Wait() + return counter.AvgMbps(), counter.Total(), nil } @@ -301,8 +326,9 @@ func (s *Server) Upload(noPrealloc, silent, useBytes, useMebi bool, requests int if noPrealloc { output.WriteUI("Pre-allocation is disabled, performance might be lower!\n") - counter.reader = &SeekWrapper{rand.Reader} } else { + // each request reads from this shared payload; without it they stream + // straight from crypto/rand instead counter.GenerateBlob() } @@ -318,7 +344,11 @@ func (s *Server) Upload(noPrealloc, silent, useBytes, useMebi bool, requests int uploadDone := make(chan struct{}, requests) + var wg sync.WaitGroup + doUpload := func() { + defer wg.Done() + var bodyReader io.Reader if noPrealloc { bodyReader = &SeekWrapper{rand.Reader} @@ -336,21 +366,37 @@ func (s *Server) Upload(noPrealloc, silent, useBytes, useMebi bool, requests int uploadReq.Header.Set("Accept-Encoding", "identity") resp, err := http.DefaultClient.Do(uploadReq) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - output.WriteDebug("Failed when making HTTP request: %s\n", err) - } else if err == nil { - defer resp.Body.Close() - if _, err := io.Copy(io.Discard, resp.Body); err != nil { + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + output.WriteDebug("Failed when making HTTP request: %s\n", err) + } + return + } + defer resp.Body.Close() + + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + // cancellation is how the test ends, so it is not a failure + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { output.WriteDebug("Failed when reading HTTP response: %s\n", err) } + } - uploadDone <- struct{}{} + // let the main loop start a replacement request, but never block on it + // once the test is over, otherwise this goroutine is leaked + select { + case uploadDone <- struct{}{}: + case <-ctx.Done(): } } + spawnUpload := func() { + wg.Add(1) + go doUpload() + } + counter.Start() if !silent { - pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond) + pb := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) pb.Prefix = "Uploading... " pb.PostUpdate = func(s *spinner.Spinner) { if useBytes { @@ -361,18 +407,21 @@ func (s *Server) Upload(noPrealloc, silent, useBytes, useMebi bool, requests int } pb.Start() + // print the rate ourselves instead of via pb.FinalMSG: the spinner only + // prints it when it was actually running, which it isn't when stderr is + // not a terminal defer func() { + pb.Stop() if useBytes { - pb.FinalMSG = fmt.Sprintf("Upload rate:\t%s\n", counter.AvgHumanize()) + output.WriteUI("Upload rate:\t%s\n", counter.AvgHumanize()) } else { - pb.FinalMSG = fmt.Sprintf("Upload rate:\t%.2f Mbps\n", counter.AvgMbps()) + output.WriteUI("Upload rate:\t%.2f Mbps\n", counter.AvgMbps()) } - pb.Stop() }() } for i := 0; i < requests; i++ { - go doUpload() + spawnUpload() time.Sleep(200 * time.Millisecond) } timeout := time.After(duration) @@ -383,10 +432,14 @@ Loop: cancel() break Loop case <-uploadDone: - go doUpload() + spawnUpload() } } + // let the cancelled requests unwind before reading the counter, so the + // result doesn't change under us while it's being reported + wg.Wait() + return counter.AvgMbps(), counter.Total(), nil } @@ -432,7 +485,8 @@ func (s *Server) GetIPInfo(distanceUnit string) (*GetIPResult, error) { if len(b) > 0 { if err := json.Unmarshal(b, &ipInfo); err != nil { output.WriteDebug("Failed when parsing get IP result: %s\n", err) - output.WriteDebug("Received payload: %s\n", b) + // %q rather than Sanitize: see IsUp + output.WriteDebug("Received payload: %q\n", b) // try to extract processedString even if full parse fails // (e.g. when rawIspInfo is "" instead of an object) var partial struct { @@ -473,7 +527,7 @@ func (s *Server) Sponsor() string { if s.SponsorURL != "" { su, err := url.Parse(s.SponsorURL) if err != nil { - output.WriteDebug("Sponsor URL is invalid: %s\n", s.SponsorURL) + output.WriteDebug("Sponsor URL is invalid: %s\n", output.Sanitize(s.SponsorURL)) } else { if su.Scheme == "" { su.Scheme = "https" diff --git a/defs/server_test.go b/defs/server_test.go new file mode 100644 index 0000000..a3e27fc --- /dev/null +++ b/defs/server_test.go @@ -0,0 +1,120 @@ +package defs + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// The transfer tests below cover the timing semantics introduced by wg.Wait(): +// Download and Upload now return only once every in-flight request has +// unwound. Against a server that never ends a request on its own, the only +// thing that can end them is the context cancellation, so these fail (by +// timing out) if wg.Wait() can hang. + +const ( + testRequests = 2 + // long enough that the requests are still in flight when the test ends + testDuration = 500 * time.Millisecond + // The spawn loop sleeps 200ms per request before the duration timer even + // starts, so a healthy run is ~900ms; measured unwind after cancel() is + // 10-40ms, with or without -race. The rest is slack for a loaded runner -- + // kept tight so a hanging wg.Wait() fails fast instead of stalling CI. + returnBudget = testRequests*200*time.Millisecond + testDuration + 2*time.Second +) + +// hangingDownloadServer streams forever until the client goes away. +func hangingDownloadServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + chunk := make([]byte, 32*1024) + for { + select { + case <-r.Context().Done(): + return + default: + } + if _, err := w.Write(chunk); err != nil { + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + })) +} + +// hangingUploadServer drains the body, then holds the request open. +func hangingUploadServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + <-r.Context().Done() + })) +} + +func runWithinBudget(t *testing.T, name string, fn func() error) { + t.Helper() + + done := make(chan error, 1) + start := time.Now() + go func() { done <- fn() }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("%s returned error: %v", name, err) + } + if elapsed := time.Since(start); elapsed > returnBudget { + t.Errorf("%s took %s, budget was %s", name, elapsed, returnBudget) + } + case <-time.After(returnBudget): + t.Fatalf("%s did not return within %s: wg.Wait() is hanging on cancelled requests", name, returnBudget) + } +} + +func TestDownloadReturnsWhenServerNeverEndsTheResponse(t *testing.T) { + ts := hangingDownloadServer(t) + defer ts.Close() + + s := &Server{Server: ts.URL, DownloadURL: "/"} + + var total uint64 + runWithinBudget(t, "Download", func() error { + _, n, err := s.Download(true, false, false, testRequests, 100, testDuration) + total = n + return err + }) + + if total == 0 { + t.Error("Download reported 0 bytes, expected the counter to have seen traffic") + } +} + +func TestUploadReturnsWhenServerNeverResponds(t *testing.T) { + ts := hangingUploadServer(t) + defer ts.Close() + + s := &Server{Server: ts.URL, UploadURL: "/"} + + runWithinBudget(t, "Upload", func() error { + _, _, err := s.Upload(false, true, false, false, testRequests, 32, testDuration) + return err + }) +} + +// The no-prealloc path streams from crypto/rand, so the request body never +// ends on its own either; only cancellation can stop it. +func TestUploadNoPreallocReturnsWhenServerNeverResponds(t *testing.T) { + ts := hangingUploadServer(t) + defer ts.Close() + + s := &Server{Server: ts.URL, UploadURL: "/"} + + runWithinBudget(t, "Upload(noPrealloc)", func() error { + _, _, err := s.Upload(true, true, false, false, testRequests, 32, testDuration) + return err + }) +} diff --git a/output/output.go b/output/output.go index 6a5d915..4462b55 100644 --- a/output/output.go +++ b/output/output.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "os" + "strings" ) // Default is the package-level output writer used by all package functions. @@ -127,3 +128,26 @@ func (w *Writer) Fatalf(format string, args ...interface{}) { fmt.Fprintln(w.ui) os.Exit(1) } + +// --- Sanitize: make server-supplied strings safe to display --- + +// Sanitize strips control characters from a string so it can be printed +// without letting a remote party drive the terminal. +// +// Server names, sponsor strings and the getIP response all come off the wire +// (over plain HTTP for schemeless servers), so they are attacker-influenced. +// Left raw, an embedded ESC sequence can rewrite earlier lines, hide text or +// recolour the output, and an embedded newline can forge an extra entry in +// --list output that a script would then parse as real. +// +// Dropped: C0 controls (including ESC, CR, LF and TAB), DEL, and C1 controls +// (0x80-0x9F, where 0x9B doubles as CSI on some terminals). Printable Unicode +// is left alone. +func Sanitize(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return -1 + } + return r + }, s) +} diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..8722e2c --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,54 @@ +package output + +import "testing" + +func TestSanitize(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"plain text is untouched", "normal text", "normal text"}, + {"newline is dropped", "hello\nworld", "helloworld"}, + {"carriage return is dropped", "hello\rworld", "helloworld"}, + {"tab is dropped", "hello\tworld", "helloworld"}, + {"nul is dropped", "hello\x00world", "helloworld"}, + {"ANSI colour sequence loses its ESC", "\x1b[31mred\x1b[0m", "[31mred[0m"}, + {"OSC window title sequence loses ESC and BEL", "\x1b]0;pwned\x07", "]0;pwned"}, + {"DEL is dropped", "foo\x7fbar", "foobar"}, + {"C1 CSI is dropped", "foo›31mbar", "foo31mbar"}, + {"C1 lower bound U+0080 is dropped", "foo€bar", "foobar"}, + {"C1 upper bound U+009F is dropped", "fooŸbar", "foobar"}, + {"U+00A0 just past C1 is kept", "foo bar", "foo bar"}, + {"space just past C0 is kept", "foo bar", "foo bar"}, + {"non-ASCII text is kept", "Český server \U0001f1e8\U0001f1ff", "Český server \U0001f1e8\U0001f1ff"}, + {"forged --list entry is collapsed onto one line", "Server A\n999: Fake", "Server A999: Fake"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Sanitize(tt.in); got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// TestSanitizeDropsEveryControlRune asserts the boundaries exhaustively rather +// than by example, so a future rewrite cannot silently let one class through. +func TestSanitizeDropsEveryControlRune(t *testing.T) { + for r := rune(0); r <= 0x9f; r++ { + if r >= 0x20 && r <= 0x7e { + continue // printable ASCII + } + if got := Sanitize(string(r)); got != "" { + t.Errorf("Sanitize(%U) = %q, want it to be dropped", r, got) + } + } + for _, r := range []rune{0x20, 0x7e, 0xa0, 0xa1, 0x2028, 0x1f600} { + if got := Sanitize(string(r)); got != string(r) { + t.Errorf("Sanitize(%U) = %q, want it kept", r, got) + } + } +} diff --git a/speedtest/helper.go b/speedtest/helper.go index a4b00ac..49fc71f 100644 --- a/speedtest/helper.go +++ b/speedtest/helper.go @@ -16,8 +16,8 @@ import ( "github.com/briandowns/spinner" "github.com/gocarina/gocsv" "github.com/librespeed/speedtest-cli/defs" - "github.com/librespeed/speedtest-cli/report" "github.com/librespeed/speedtest-cli/output" + "github.com/librespeed/speedtest-cli/report" "github.com/urfave/cli/v2" ) @@ -46,10 +46,10 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel return err } - output.WriteUI("Selected server: %s [%s]\n", currentServer.Name, u.Hostname()) + output.WriteUI("Selected server: %s [%s]\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname())) if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" { - output.WriteUI("Sponsored by: %s\n", sponsorMsg) + output.WriteUI("Sponsored by: %s\n", output.Sanitize(sponsorMsg)) } if currentServer.IsUp() { @@ -58,12 +58,12 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel output.WriteError("Failed to get IP info: %s\n", err) return err } - output.WriteUI("You're testing from: %s\n", ispInfo.ProcessedString) + output.WriteUI("You're testing from: %s\n", output.Sanitize(ispInfo.ProcessedString)) // get ping and jitter value var pb *spinner.Spinner if !silent { - pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond) + pb = spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriterFile(os.Stderr)) pb.Prefix = "Pinging server... " pb.Start() } @@ -78,8 +78,11 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel } if pb != nil { - pb.FinalMSG = fmt.Sprintf("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter) + // print the result ourselves instead of via pb.FinalMSG: the + // spinner only prints it when it was actually running, which it + // isn't when stderr is not a terminal pb.Stop() + output.WriteUI("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter) } // get download value @@ -113,14 +116,14 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel } // print result if --simple is given - if c.Bool(defs.OptionSimple) { - if c.Bool(defs.OptionBytes) { - useMebi := c.Bool(defs.OptionMebiBytes) - output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi)) - } else { - output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue) + if c.Bool(defs.OptionSimple) { + if c.Bool(defs.OptionBytes) { + useMebi := c.Bool(defs.OptionMebiBytes) + output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s\n", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi)) + } else { + output.WriteOut("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps\n", p, jitter, downloadValue, uploadValue) + } } - } // print share link if --share is given var shareLink string @@ -182,7 +185,7 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel reps_json = append(reps_json, rep) } } else { - output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", currentServer.Name, u.Hostname()) + output.WriteUI("Selected server %s (%s) is not responding at the moment, try again later\n", output.Sanitize(currentServer.Name), output.Sanitize(u.Hostname())) } //add a new line after each test if testing multiple servers diff --git a/speedtest/speedtest.go b/speedtest/speedtest.go index 769228d..90f5c48 100644 --- a/speedtest/speedtest.go +++ b/speedtest/speedtest.go @@ -33,8 +33,8 @@ const ( defaultTelemetryShare = "/results/" forceNothing = 0 - forceHttps = 1 - forceHttp = 2 + forceHttps = 1 + forceHttp = 2 ) type PingJob struct { @@ -283,9 +283,11 @@ func SpeedTest(c *cli.Context) error { for _, svr := range servers { var sponsorMsg string if svr.Sponsor() != "" { - sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", svr.Sponsor()) + sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", output.Sanitize(svr.Sponsor())) } - output.WriteOut("%d: %s (%s) %s\n", svr.ID, svr.Name, svr.Server, sponsorMsg) + // --list goes to stdout, so a newline smuggled into a server name + // would forge an entry for anything parsing it + output.WriteOut("%d: %s (%s) %s\n", svr.ID, output.Sanitize(svr.Name), output.Sanitize(svr.Server), sponsorMsg) } return nil } @@ -360,7 +362,7 @@ func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGro // get the URL of the speed test server from the JSON u, err := server.GetURL() if err != nil { - output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", server.Name, server.Server) + output.WriteDebug("Server URL is invalid for %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(server.Server)) wg.Done() continue } @@ -373,7 +375,7 @@ func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGro // if server is up, get ping ping, _, err := server.ICMPPingAndJitter(1, srcIp, network) if err != nil { - output.WriteDebug("Can't ping server %s (%s), skipping\n", server.Name, u.Hostname()) + output.WriteDebug("Can't ping server %s (%s), skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname())) wg.Done() continue } @@ -381,7 +383,7 @@ func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGro results <- PingResult{Index: job.Index, Ping: ping} wg.Done() } else { - output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", server.Name, u.Hostname()) + output.WriteDebug("Server %s (%s) doesn't seem to be up, skipping\n", output.Sanitize(server.Name), output.Sanitize(u.Hostname())) wg.Done() } }