You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
515 lines
15 KiB
515 lines
15 KiB
|
2 months ago
|
// Package diagnostics runs bounded, read-only target diagnostics without a
|
||
|
|
// shell. It never accepts a command name or arbitrary argument list from API
|
||
|
|
// clients.
|
||
|
|
package diagnostics
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"net"
|
||
|
|
"os/exec"
|
||
|
|
"path"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
KindNetworkTest = "network_test"
|
||
|
|
KindTraceroute = "traceroute"
|
||
|
|
KindTCPConnectionTest = "tcp_connection_test"
|
||
|
|
DefaultTracerouteMaxHops = 10
|
||
|
|
MaximumTracerouteMaxHops = 64
|
||
|
|
TracerouteProbeTimeoutSeconds = 3
|
||
|
|
maxTimeout = 300
|
||
|
|
maxOutput = 1024 * 1024
|
||
|
|
)
|
||
|
|
|
||
|
|
type Request struct {
|
||
|
|
Kind string
|
||
|
|
Target string
|
||
|
|
Port int
|
||
|
|
Count int
|
||
|
|
MaxHops int
|
||
|
|
TimeoutSeconds int
|
||
|
|
MaxOutputBytes int
|
||
|
|
}
|
||
|
|
|
||
|
|
type Result struct {
|
||
|
|
Kind string
|
||
|
|
Target string
|
||
|
|
Port int
|
||
|
|
Count int
|
||
|
|
MaxHops int
|
||
|
|
ExitCode int
|
||
|
|
Output string
|
||
|
|
OutputBytes int
|
||
|
|
OutputTruncated bool
|
||
|
|
Reachable *bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r Result) OperationResult(timeoutSeconds, maxOutputBytes int) map[string]any {
|
||
|
|
result := map[string]any{
|
||
|
|
"kind": r.Kind,
|
||
|
|
"target": r.Target,
|
||
|
|
"timeout_seconds": timeoutSeconds,
|
||
|
|
"max_output_bytes": maxOutputBytes,
|
||
|
|
"exit_code": r.ExitCode,
|
||
|
|
"output": r.Output,
|
||
|
|
"output_bytes": r.OutputBytes,
|
||
|
|
"output_truncated": r.OutputTruncated,
|
||
|
|
"adapter": "process",
|
||
|
|
}
|
||
|
|
if r.Reachable != nil {
|
||
|
|
result["reachable"] = *r.Reachable
|
||
|
|
}
|
||
|
|
if r.Port != 0 {
|
||
|
|
result["port"] = r.Port
|
||
|
|
result["adapter"] = "socket"
|
||
|
|
}
|
||
|
|
if r.Count != 0 {
|
||
|
|
result["count"] = r.Count
|
||
|
|
}
|
||
|
|
if r.MaxHops != 0 {
|
||
|
|
result["max_hops"] = r.MaxHops
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func TracerouteOperationTimeoutSeconds(maxHops int) int {
|
||
|
|
if maxHops < 1 || maxHops > MaximumTracerouteMaxHops {
|
||
|
|
maxHops = DefaultTracerouteMaxHops
|
||
|
|
}
|
||
|
|
return maxHops*TracerouteProbeTimeoutSeconds + 5
|
||
|
|
}
|
||
|
|
|
||
|
|
type Runner interface {
|
||
|
|
Run(context.Context, Request) (Result, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type RunnerFunc func(context.Context, Request) (Result, error)
|
||
|
|
|
||
|
|
func (f RunnerFunc) Run(ctx context.Context, request Request) (Result, error) {
|
||
|
|
return f(ctx, request)
|
||
|
|
}
|
||
|
|
|
||
|
|
type Execution struct {
|
||
|
|
Output string
|
||
|
|
Bytes int
|
||
|
|
Truncated bool
|
||
|
|
ExitCode int
|
||
|
|
}
|
||
|
|
|
||
|
|
type OutputProgress struct {
|
||
|
|
Output string
|
||
|
|
Bytes int
|
||
|
|
Truncated bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type outputReporterContextKey struct{}
|
||
|
|
|
||
|
|
func WithOutputReporter(ctx context.Context, reporter func(OutputProgress)) context.Context {
|
||
|
|
if reporter == nil {
|
||
|
|
return ctx
|
||
|
|
}
|
||
|
|
return context.WithValue(ctx, outputReporterContextKey{}, reporter)
|
||
|
|
}
|
||
|
|
|
||
|
|
func ReportOutput(ctx context.Context, progress OutputProgress) {
|
||
|
|
reporter, _ := ctx.Value(outputReporterContextKey{}).(func(OutputProgress))
|
||
|
|
if reporter != nil {
|
||
|
|
reporter(progress)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type Executor interface {
|
||
|
|
Execute(context.Context, string, []string, int) (Execution, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type ProcessRunnerOptions struct {
|
||
|
|
PingPath string
|
||
|
|
TraceroutePath string
|
||
|
|
Executor Executor
|
||
|
|
}
|
||
|
|
|
||
|
|
type ProcessRunner struct {
|
||
|
|
pingPath string
|
||
|
|
traceroutePath string
|
||
|
|
executor Executor
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewProcessRunner(options ProcessRunnerOptions) (*ProcessRunner, error) {
|
||
|
|
if options.PingPath == "" {
|
||
|
|
options.PingPath = "/bin/ping"
|
||
|
|
}
|
||
|
|
if options.TraceroutePath == "" {
|
||
|
|
options.TraceroutePath = "/usr/bin/traceroute"
|
||
|
|
}
|
||
|
|
if !isAbsoluteExecutablePath(options.PingPath) || !isAbsoluteExecutablePath(options.TraceroutePath) {
|
||
|
|
return nil, errors.New("diagnostic executable paths must be absolute")
|
||
|
|
}
|
||
|
|
if options.Executor == nil {
|
||
|
|
options.Executor = OSExecutor{}
|
||
|
|
}
|
||
|
|
return &ProcessRunner{
|
||
|
|
pingPath: cleanExecutablePath(options.PingPath),
|
||
|
|
traceroutePath: cleanExecutablePath(options.TraceroutePath),
|
||
|
|
executor: options.Executor,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *ProcessRunner) Run(ctx context.Context, request Request) (Result, error) {
|
||
|
|
if err := ValidateRequest(request); err != nil {
|
||
|
|
return Result{}, err
|
||
|
|
}
|
||
|
|
if request.Kind == KindTCPConnectionTest {
|
||
|
|
return runTCPConnectionTest(ctx, request)
|
||
|
|
}
|
||
|
|
if request.Kind == KindTraceroute {
|
||
|
|
return r.runPingTraceroute(ctx, request)
|
||
|
|
}
|
||
|
|
pingCount := request.Count
|
||
|
|
if pingCount == 0 {
|
||
|
|
pingCount = 4
|
||
|
|
}
|
||
|
|
path := r.pingPath
|
||
|
|
args := []string{"-c", strconv.Itoa(pingCount), "-s", "56", "-W", "1", "-w", strconv.Itoa(pingCount + 1), request.Target}
|
||
|
|
executionContext, cancel := context.WithTimeout(ctx, time.Duration(request.TimeoutSeconds)*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
execution, err := r.executor.Execute(executionContext, path, args, request.MaxOutputBytes)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, err
|
||
|
|
}
|
||
|
|
result := Result{
|
||
|
|
Kind: request.Kind,
|
||
|
|
Target: request.Target,
|
||
|
|
Count: mapPingCount(request.Kind, pingCount),
|
||
|
|
ExitCode: execution.ExitCode,
|
||
|
|
Output: execution.Output,
|
||
|
|
OutputBytes: execution.Bytes,
|
||
|
|
OutputTruncated: execution.Truncated,
|
||
|
|
}
|
||
|
|
if request.Kind == KindNetworkTest {
|
||
|
|
reachable := execution.ExitCode == 0
|
||
|
|
result.Reachable = &reachable
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *ProcessRunner) runPingTraceroute(ctx context.Context, request Request) (Result, error) {
|
||
|
|
maxHops := request.MaxHops
|
||
|
|
if maxHops == 0 {
|
||
|
|
maxHops = DefaultTracerouteMaxHops
|
||
|
|
}
|
||
|
|
executionContext, cancel := context.WithTimeout(ctx, time.Duration(request.TimeoutSeconds)*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
lines := []string{fmt.Sprintf("traceroute to %s, %d hops max, ICMP ping probes", request.Target, maxHops)}
|
||
|
|
reachable := false
|
||
|
|
exitCode := 1
|
||
|
|
truncated := false
|
||
|
|
for ttl := 1; ttl <= maxHops; ttl++ {
|
||
|
|
probeContext := context.WithValue(executionContext, outputReporterContextKey{}, (func(OutputProgress))(nil))
|
||
|
|
execution, err := r.executor.Execute(probeContext, r.pingPath, []string{
|
||
|
|
"-c", "1", "-s", "56", "-W", "1", "-t", strconv.Itoa(ttl), request.Target,
|
||
|
|
}, request.MaxOutputBytes)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
hop, reached, unreachable := pingProbeHop(request.Target, execution)
|
||
|
|
lines = append(lines, fmt.Sprintf("%2d %s", ttl, hop))
|
||
|
|
output, outputTruncated := boundedOutput(strings.Join(lines, "\n"), request.MaxOutputBytes)
|
||
|
|
truncated = truncated || outputTruncated || execution.Truncated
|
||
|
|
ReportOutput(executionContext, OutputProgress{Output: output, Bytes: len(output), Truncated: truncated})
|
||
|
|
if reached {
|
||
|
|
reachable = true
|
||
|
|
exitCode = 0
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if unreachable {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
output, outputTruncated := boundedOutput(strings.Join(lines, "\n"), request.MaxOutputBytes)
|
||
|
|
truncated = truncated || outputTruncated
|
||
|
|
return Result{
|
||
|
|
Kind: request.Kind,
|
||
|
|
Target: request.Target,
|
||
|
|
MaxHops: maxHops,
|
||
|
|
ExitCode: exitCode,
|
||
|
|
Output: output,
|
||
|
|
OutputBytes: len(output),
|
||
|
|
OutputTruncated: truncated,
|
||
|
|
Reachable: &reachable,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func pingProbeHop(target string, execution Execution) (hop string, reached bool, unreachable bool) {
|
||
|
|
for _, line := range strings.Split(strings.ToValidUTF8(execution.Output, ""), "\n") {
|
||
|
|
trimmed := strings.TrimSpace(line)
|
||
|
|
lower := strings.ToLower(trimmed)
|
||
|
|
if index := strings.Index(lower, " bytes from "); index >= 0 {
|
||
|
|
detail := trimmed[index+len(" bytes from "):]
|
||
|
|
if marker := strings.Index(strings.ToLower(detail), " icmp_seq="); marker >= 0 {
|
||
|
|
detail = strings.TrimSuffix(strings.TrimSpace(detail[:marker]), ":")
|
||
|
|
}
|
||
|
|
if latency := pingLatency(trimmed); latency != "" {
|
||
|
|
detail += " " + latency
|
||
|
|
}
|
||
|
|
return detail, true, false
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(lower, "from ") {
|
||
|
|
detail := strings.TrimSpace(trimmed[len("From "):])
|
||
|
|
if marker := strings.Index(strings.ToLower(detail), " icmp_seq="); marker >= 0 {
|
||
|
|
detail = strings.TrimSpace(detail[:marker])
|
||
|
|
}
|
||
|
|
if strings.Contains(lower, "unreachable") || strings.Contains(lower, "prohibited") {
|
||
|
|
return detail + " !H", false, true
|
||
|
|
}
|
||
|
|
if strings.Contains(lower, "time to live exceeded") || strings.Contains(lower, "ttl expired") {
|
||
|
|
return detail, false, false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if execution.ExitCode == 0 {
|
||
|
|
return target, true, false
|
||
|
|
}
|
||
|
|
return "*", false, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func pingLatency(line string) string {
|
||
|
|
lower := strings.ToLower(line)
|
||
|
|
for _, marker := range []string{"time=", "time<"} {
|
||
|
|
if index := strings.Index(lower, marker); index >= 0 {
|
||
|
|
value := strings.Fields(line[index+len(marker):])
|
||
|
|
if len(value) > 0 {
|
||
|
|
prefix := ""
|
||
|
|
if marker == "time<" {
|
||
|
|
prefix = "<"
|
||
|
|
}
|
||
|
|
return prefix + strings.TrimSuffix(value[0], "ms") + " ms"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func boundedOutput(output string, limit int) (string, bool) {
|
||
|
|
if len(output) <= limit {
|
||
|
|
return output, false
|
||
|
|
}
|
||
|
|
return strings.ToValidUTF8(output[:limit], ""), true
|
||
|
|
}
|
||
|
|
|
||
|
|
func tracerouteReached(target, output string, exitCode int) bool {
|
||
|
|
if exitCode != 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||
|
|
for index := len(lines) - 1; index >= 0; index-- {
|
||
|
|
line := strings.TrimSpace(lines[index])
|
||
|
|
if line == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
lower := strings.ToLower(line)
|
||
|
|
if strings.Contains(lower, "unreachable") || strings.Contains(lower, "no route") || strings.Contains(line, " !") {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if net.ParseIP(target) != nil {
|
||
|
|
for _, field := range strings.Fields(line) {
|
||
|
|
if strings.Trim(field, "()") == target {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return !strings.Contains(line, "*")
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func runTCPConnectionTest(ctx context.Context, request Request) (Result, error) {
|
||
|
|
executionContext, cancel := context.WithTimeout(ctx, time.Duration(request.TimeoutSeconds)*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
connection, err := (&net.Dialer{}).DialContext(executionContext, "tcp", net.JoinHostPort(request.Target, strconv.Itoa(request.Port)))
|
||
|
|
connected := err == nil
|
||
|
|
output := "tcp connection established"
|
||
|
|
exitCode := 0
|
||
|
|
if err != nil {
|
||
|
|
output = strings.ToValidUTF8(err.Error(), "")
|
||
|
|
exitCode = 1
|
||
|
|
} else {
|
||
|
|
_ = connection.Close()
|
||
|
|
}
|
||
|
|
outputBytes := len(output)
|
||
|
|
truncated := outputBytes > request.MaxOutputBytes
|
||
|
|
if len(output) > request.MaxOutputBytes {
|
||
|
|
output = output[:request.MaxOutputBytes]
|
||
|
|
}
|
||
|
|
return Result{
|
||
|
|
Kind: request.Kind, Target: request.Target, Port: request.Port, ExitCode: exitCode,
|
||
|
|
Output: output, OutputBytes: len(output), OutputTruncated: truncated,
|
||
|
|
Reachable: &connected,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ValidateRequest(request Request) error {
|
||
|
|
if request.Kind != KindNetworkTest && request.Kind != KindTraceroute && request.Kind != KindTCPConnectionTest {
|
||
|
|
return errors.New("diagnostic kind must be network_test, traceroute, or tcp_connection_test")
|
||
|
|
}
|
||
|
|
if err := ValidateTarget(request.Target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if request.TimeoutSeconds < 1 || request.TimeoutSeconds > maxTimeout {
|
||
|
|
return fmt.Errorf("diagnostic timeout_seconds must be between 1 and %d", maxTimeout)
|
||
|
|
}
|
||
|
|
if request.MaxOutputBytes < 1024 || request.MaxOutputBytes > maxOutput {
|
||
|
|
return fmt.Errorf("diagnostic max_output_bytes must be between 1024 and %d", maxOutput)
|
||
|
|
}
|
||
|
|
if request.Kind == KindTCPConnectionTest && (request.Port < 1 || request.Port > 65535) {
|
||
|
|
return errors.New("diagnostic port must be between 1 and 65535 for tcp_connection_test")
|
||
|
|
}
|
||
|
|
if request.Kind == KindNetworkTest && request.Count != 0 && (request.Count < 1 || request.Count > 20) {
|
||
|
|
return errors.New("diagnostic count must be between 1 and 20 for network_test")
|
||
|
|
}
|
||
|
|
if request.Kind == KindTraceroute && request.MaxHops != 0 && (request.MaxHops < 1 || request.MaxHops > MaximumTracerouteMaxHops) {
|
||
|
|
return fmt.Errorf("diagnostic max_hops must be between 1 and %d for traceroute", MaximumTracerouteMaxHops)
|
||
|
|
}
|
||
|
|
if request.Kind != KindTraceroute && request.MaxHops != 0 {
|
||
|
|
return errors.New("diagnostic max_hops is only valid for traceroute")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func mapPingCount(kind string, count int) int {
|
||
|
|
if kind != KindNetworkTest {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return count
|
||
|
|
}
|
||
|
|
|
||
|
|
func ValidateTarget(target string) error {
|
||
|
|
if target == "" {
|
||
|
|
return errors.New("diagnostic target must not be empty")
|
||
|
|
}
|
||
|
|
if len(target) > 128 {
|
||
|
|
return errors.New("diagnostic target must be 128 characters or fewer")
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(target, "-") {
|
||
|
|
return errors.New("diagnostic target must not begin with a command option")
|
||
|
|
}
|
||
|
|
for _, char := range target {
|
||
|
|
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == '_' || char == ':' {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return errors.New("diagnostic target contains invalid characters")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type OSExecutor struct{}
|
||
|
|
|
||
|
|
func (OSExecutor) Execute(ctx context.Context, path string, args []string, maxOutputBytes int) (Execution, error) {
|
||
|
|
if !isAbsoluteExecutablePath(path) {
|
||
|
|
return Execution{}, errors.New("diagnostic executable path must be absolute")
|
||
|
|
}
|
||
|
|
if maxOutputBytes <= 0 || maxOutputBytes > maxOutput {
|
||
|
|
return Execution{}, errors.New("diagnostic output limit is invalid")
|
||
|
|
}
|
||
|
|
command := exec.CommandContext(ctx, path, args...)
|
||
|
|
buffer := &limitedBuffer{
|
||
|
|
limit: maxOutputBytes,
|
||
|
|
reporter: func(progress OutputProgress) {
|
||
|
|
ReportOutput(ctx, progress)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
command.Stdout = buffer
|
||
|
|
command.Stderr = buffer
|
||
|
|
err := command.Run()
|
||
|
|
execution := Execution{
|
||
|
|
Output: strings.ToValidUTF8(buffer.String(), ""),
|
||
|
|
Bytes: buffer.Len(),
|
||
|
|
Truncated: buffer.Truncated(),
|
||
|
|
ExitCode: 0,
|
||
|
|
}
|
||
|
|
if err == nil {
|
||
|
|
return execution, nil
|
||
|
|
}
|
||
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||
|
|
return execution, ctxErr
|
||
|
|
}
|
||
|
|
var exitError *exec.ExitError
|
||
|
|
if errors.As(err, &exitError) {
|
||
|
|
execution.ExitCode = exitError.ExitCode()
|
||
|
|
return execution, nil
|
||
|
|
}
|
||
|
|
return execution, fmt.Errorf("start diagnostic executable: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
func isAbsoluteExecutablePath(value string) bool {
|
||
|
|
return filepath.IsAbs(value) || path.IsAbs(value)
|
||
|
|
}
|
||
|
|
|
||
|
|
func cleanExecutablePath(value string) string {
|
||
|
|
if path.IsAbs(value) && !filepath.IsAbs(value) {
|
||
|
|
return path.Clean(value)
|
||
|
|
}
|
||
|
|
return filepath.Clean(value)
|
||
|
|
}
|
||
|
|
|
||
|
|
type limitedBuffer struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
content []byte
|
||
|
|
limit int
|
||
|
|
truncated bool
|
||
|
|
reporter func(OutputProgress)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *limitedBuffer) Write(value []byte) (int, error) {
|
||
|
|
b.mu.Lock()
|
||
|
|
remaining := b.limit - len(b.content)
|
||
|
|
if remaining > 0 {
|
||
|
|
copyLength := min(remaining, len(value))
|
||
|
|
b.content = append(b.content, value[:copyLength]...)
|
||
|
|
}
|
||
|
|
if len(value) > remaining {
|
||
|
|
b.truncated = true
|
||
|
|
}
|
||
|
|
progress := OutputProgress{
|
||
|
|
Output: strings.ToValidUTF8(string(b.content), ""),
|
||
|
|
Bytes: len(b.content),
|
||
|
|
Truncated: b.truncated,
|
||
|
|
}
|
||
|
|
reporter := b.reporter
|
||
|
|
b.mu.Unlock()
|
||
|
|
if reporter != nil {
|
||
|
|
reporter(progress)
|
||
|
|
}
|
||
|
|
// Report the full write so os/exec can continue draining the child pipes
|
||
|
|
// without retaining more output in memory.
|
||
|
|
return len(value), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *limitedBuffer) String() string {
|
||
|
|
b.mu.Lock()
|
||
|
|
defer b.mu.Unlock()
|
||
|
|
return string(b.content)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *limitedBuffer) Len() int {
|
||
|
|
b.mu.Lock()
|
||
|
|
defer b.mu.Unlock()
|
||
|
|
return len(b.content)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *limitedBuffer) Truncated() bool {
|
||
|
|
b.mu.Lock()
|
||
|
|
defer b.mu.Unlock()
|
||
|
|
return b.truncated
|
||
|
|
}
|