From 9227fcd879e7722447cb9ae0654269241e4eb3b9 Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 10 May 2023 19:30:16 +0000 Subject: [PATCH] feat(bsr/convert): Add logic to convert ssh channel to asciicast --- internal/bsr/convert/errors.go | 12 + internal/bsr/convert/ssh.go | 146 +++++++ internal/bsr/convert/ssh_test.go | 707 +++++++++++++++++++++++++++++++ 3 files changed, 865 insertions(+) create mode 100644 internal/bsr/convert/errors.go create mode 100644 internal/bsr/convert/ssh.go create mode 100644 internal/bsr/convert/ssh_test.go diff --git a/internal/bsr/convert/errors.go b/internal/bsr/convert/errors.go new file mode 100644 index 000000000..55075ebff --- /dev/null +++ b/internal/bsr/convert/errors.go @@ -0,0 +1,12 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package convert + +import "errors" + +// Common convert errors. +var ( + ErrUnsupportedProtocol = errors.New("unsupported protocol") + ErrMalformedBsr = errors.New("malformed bsr data file") +) diff --git a/internal/bsr/convert/ssh.go b/internal/bsr/convert/ssh.go new file mode 100644 index 000000000..3120a6f69 --- /dev/null +++ b/internal/bsr/convert/ssh.go @@ -0,0 +1,146 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package convert + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/hashicorp/boundary/internal/bsr" + "github.com/hashicorp/boundary/internal/bsr/convert/internal/asciicast" + "github.com/hashicorp/boundary/internal/bsr/internal/is" + "github.com/hashicorp/boundary/internal/bsr/ssh" +) + +// sshChannelToAsciicast will convert a recording of an ssh channel from a BSR +// into an asciicast. This expects two bsr.ChunkScanners. One for the recording +// of ssh requests and one for the recording of messages. In order to generate +// a useful asciicast, it should be the inbound requests and outbound messages. +// This also expects a io.ReadWriteSeeker that will be used to write the +// asciicast. This is then reset and returned as a io.ReadCloser. The caller +// should call Close on the returned io.ReadCloser after reading the asciicast. +func sshChannelToAsciicast(ctx context.Context, requestScanner *bsr.ChunkScanner, messagesScanner *bsr.ChunkScanner, w io.ReadWriteSeeker) (io.ReadCloser, error) { + const op = "convert.sshChannelToAsciicast" + + switch { + case is.Nil(requestScanner): + return nil, fmt.Errorf("%s: missing request scanner: %w", op, bsr.ErrInvalidParameter) + case is.Nil(messagesScanner): + return nil, fmt.Errorf("%s: missing message scanner: %w", op, bsr.ErrInvalidParameter) + case is.Nil(w): + return nil, fmt.Errorf("%s: missing read write seeker: %w", op, bsr.ErrInvalidParameter) + } + + header := asciicast.NewHeader() + + // Walk the requests to extract any information for asciicast header. This + // will come from either a Pty and/or Env request. There could also be + // additional WindowChange requests to resize the terminal. In picking an + // initial height/width for the asciicast, this will pick the largest heigh + // and width seen between a Pty and any WindowChange requests + // + // There may not be either request, so the header defaults to some sane values. + // We would expect at most one Pty request per channel. Although this is + // not required by and RFC. If there happen to be multiple, it is probably + // ok to just use the last one. + if err := bsr.ChunkWalk(ctx, requestScanner, func(ctx context.Context, c bsr.Chunk) error { + switch c.GetProtocol() { + case ssh.Protocol: + switch c.GetType() { + case ssh.PtyReqChunkType: + cc := c.(*ssh.PtyRequest) + + if cc.GetTerminalWidthCharacters() > asciicast.MinWidth { + header.Width = cc.GetTerminalWidthCharacters() + } + if cc.GetTerminalHeightRows() > asciicast.MinHeight { + header.Height = cc.GetTerminalHeightRows() + } + if cc.GetTermEnvVar() != "" { + header.Env.Term = cc.GetTermEnvVar() + } + case ssh.WindowChangeReqChunkType: + cc := c.(*ssh.WindowChangeRequest) + + if cc.GetTerminalWidthColumns() > header.Width { + header.Width = cc.GetTerminalWidthColumns() + } + if cc.GetTerminalHeightRows() > header.Height { + header.Height = cc.GetTerminalHeightRows() + } + + case ssh.EnvReqChunkType: + cc := c.(*ssh.EnvRequest) + if cc.GetVariableName() == "SHELL" && cc.GetVariableValue() != "" { + header.Env.Shell = cc.GetVariableValue() + } + } + return nil + default: + return ErrUnsupportedProtocol + } + }); err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + // Now walk the message chunks. The ChunkHeader should contain the last bit + // of information needed to complete the asciicast header; the start timestamp. + // After that it should just be creating asciicast.Event for each DataChunk. + enc := json.NewEncoder(w) + var wroteHeader bool + if err := bsr.ChunkWalk(ctx, messagesScanner, func(ctx context.Context, c bsr.Chunk) error { + switch c.GetProtocol() { + case ssh.Protocol: + switch c.GetType() { + case bsr.ChunkHeader: + cc := c.(*bsr.HeaderChunk) + if wroteHeader { + return fmt.Errorf("multiple header chunks: %w", ErrMalformedBsr) + } + header.Timestamp = asciicast.Time(cc.GetTimestamp().AsTime()) + if err := enc.Encode(&header); err != nil { + return err + } + wroteHeader = true + case ssh.DataChunkType: + if !wroteHeader { + return fmt.Errorf("data chunk before header: %w", ErrMalformedBsr) + } + cc := c.(*ssh.DataChunk) + tt := cc.GetTimestamp().AsTime() + ts := float64(tt.Sub(time.Time(header.Timestamp))) / float64(time.Second) + data := cc.Data + + e, err := asciicast.NewEvent(asciicast.Output, ts, data) + if err != nil { + return err + } + + if err := enc.Encode(e); err != nil { + return err + } + } + return nil + default: + return ErrUnsupportedProtocol + } + }); err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + if _, err := w.Seek(0, io.SeekStart); err != nil { + return nil, err + } + + var r io.ReadCloser + if v, ok := w.(io.ReadCloser); ok { + r = v + } else { + r = io.NopCloser(w) + } + return r, nil +} diff --git a/internal/bsr/convert/ssh_test.go b/internal/bsr/convert/ssh_test.go new file mode 100644 index 000000000..4a1e6ef10 --- /dev/null +++ b/internal/bsr/convert/ssh_test.go @@ -0,0 +1,707 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package convert + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/bsr" + sshv1 "github.com/hashicorp/boundary/internal/bsr/gen/ssh/v1" + "github.com/hashicorp/boundary/internal/bsr/ssh" + "github.com/stretchr/testify/require" +) + +func Test_sshChannelToAsciicast(t *testing.T) { + ctx := context.Background() + + ts := time.Date(2023, time.March, 16, 10, 47, 3, 14, time.UTC) + newW := func() io.ReadWriteSeeker { + f, err := os.CreateTemp("", "*.asciicast") + require.NoError(t, err) + t.Cleanup(func() { + os.Remove(f.Name()) + }) + return f + } + newScanner := func(chunks ...bsr.Chunk) *bsr.ChunkScanner { + var buf bytes.Buffer + buf.Write(bsr.Magic.Bytes()) + enc, err := bsr.NewChunkEncoder(ctx, &buf, bsr.NoCompression, bsr.NoEncryption) + require.NoError(t, err) + + for _, c := range chunks { + _, err := enc.Encode(ctx, c) + require.NoError(t, err) + } + s, err := bsr.NewChunkScanner(ctx, bytes.NewBuffer(buf.Bytes())) + require.NoError(t, err) + return s + } + cases := []struct { + name string + requestScanner *bsr.ChunkScanner + messageScanner *bsr.ChunkScanner + w io.ReadWriteSeeker + want []byte + wantErr error + }{ + { + "no-pty-no-env-no-messages", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":80,"height":120,"timestamp":1678963623,"env":{"SHELL":"/bin/bash","TERM":"xterm"}} +`), + nil, + }, + { + "pty-no-env-no-messages", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &ssh.PtyRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.PtyReqChunkType, + }, + PtyRequest: &sshv1.PtyRequest{ + RequestType: ssh.PtyRequestType, + WantReply: false, + TermEnvVar: "kitty", + TerminalWidthCharacters: 160, + TerminalHeightRows: 200, + TerminalWidthPixels: 0, + TerminalHeightPixels: 0, + EncodedTerminalMode: []byte{}, + }, + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":160,"height":200,"timestamp":1678963623,"env":{"SHELL":"/bin/bash","TERM":"kitty"}} +`), + nil, + }, + { + "pty-no-env-no-messages-window-change", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &ssh.PtyRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.PtyReqChunkType, + }, + PtyRequest: &sshv1.PtyRequest{ + RequestType: ssh.PtyRequestType, + WantReply: false, + TermEnvVar: "kitty", + TerminalWidthCharacters: 160, + TerminalHeightRows: 200, + TerminalWidthPixels: 0, + TerminalHeightPixels: 0, + EncodedTerminalMode: []byte{}, + }, + }, + &ssh.WindowChangeRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.WindowChangeReqChunkType, + }, + WindowChangeRequest: &sshv1.WindowChangeRequest{ + RequestType: ssh.WindowChangeRequestType, + WantReply: false, + TerminalWidthColumns: 220, + TerminalHeightRows: 100, + }, + }, + &ssh.WindowChangeRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.WindowChangeReqChunkType, + }, + WindowChangeRequest: &sshv1.WindowChangeRequest{ + RequestType: ssh.WindowChangeRequestType, + WantReply: false, + TerminalWidthColumns: 100, + TerminalHeightRows: 500, + }, + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":220,"height":500,"timestamp":1678963623,"env":{"SHELL":"/bin/bash","TERM":"kitty"}} +`), + nil, + }, + { + "pty-env-no-messages", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &ssh.PtyRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.PtyReqChunkType, + }, + PtyRequest: &sshv1.PtyRequest{ + RequestType: ssh.PtyRequestType, + WantReply: false, + TermEnvVar: "kitty", + TerminalWidthCharacters: 160, + TerminalHeightRows: 200, + TerminalWidthPixels: 0, + TerminalHeightPixels: 0, + EncodedTerminalMode: []byte{}, + }, + }, + &ssh.EnvRequest{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond + time.Nanosecond)), + Type: ssh.EnvReqChunkType, + }, + EnvRequest: &sshv1.EnvRequest{ + RequestType: ssh.EnvRequestType, + WantReply: false, + VariableName: "SHELL", + VariableValue: "/bin/fish", + }, + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":160,"height":200,"timestamp":1678963623,"env":{"SHELL":"/bin/fish","TERM":"kitty"}} +`), + nil, + }, + { + "no-pty-no-env-messages", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &ssh.DataChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.DataChunkType, + }, + Data: []byte("ls -lash"), + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(2 * time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":80,"height":120,"timestamp":1678963623,"env":{"SHELL":"/bin/bash","TERM":"xterm"}} +[0.000001,"o","ls -lash"] +`), + nil, + }, + { + "no-pty-no-env-multiple-messages", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &ssh.DataChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.DataChunkType, + }, + Data: []byte("ls -lash"), + }, + &ssh.DataChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(2 * time.Microsecond)), + Type: ssh.DataChunkType, + }, + Data: []byte("foo\r\n"), + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(2 * time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + []byte(`{"version":2,"width":80,"height":120,"timestamp":1678963623,"env":{"SHELL":"/bin/bash","TERM":"xterm"}} +[0.000001,"o","ls -lash"] +[0.000002,"o","foo\r\n"] +`), + nil, + }, + { + "nil-requestScanner", + nil, + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + nil, + errors.New("convert.sshChannelToAsciicast: missing request scanner: invalid parameter"), + }, + { + "nil-messageScanner", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + nil, + newW(), + nil, + errors.New("convert.sshChannelToAsciicast: missing message scanner: invalid parameter"), + }, + { + "nil-w", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + nil, + nil, + errors.New("convert.sshChannelToAsciicast: missing read write seeker: invalid parameter"), + }, + { + "data-before-header", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &ssh.DataChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Microsecond)), + Type: ssh.DataChunkType, + }, + Data: []byte("ls -lash"), + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(2 * time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + nil, + errors.New("convert.sshChannelToAsciicast: bsr.ChunkWalk: data chunk before header: malformed bsr data file"), + }, + { + "multiple-header", + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newScanner( + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.HeaderChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts), + Type: bsr.ChunkHeader, + }, + Compression: bsr.NoCompression, + Encryption: bsr.NoEncryption, + SessionId: "sess_123456789", + }, + &bsr.EndChunk{ + BaseChunk: &bsr.BaseChunk{ + Protocol: ssh.Protocol, + Direction: bsr.Inbound, + Timestamp: bsr.NewTimestamp(ts.Add(time.Second)), + Type: bsr.ChunkEnd, + }, + }, + ), + newW(), + nil, + errors.New("convert.sshChannelToAsciicast: bsr.ChunkWalk: multiple header chunks: malformed bsr data file"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, err := sshChannelToAsciicast( + ctx, + tc.requestScanner, + tc.messageScanner, + tc.w, + ) + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + require.NoError(t, err) + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, string(tc.want), string(got)) + + err = r.Close() + require.NoError(t, err) + }) + } +}