feat: add shell interface, registry, and bash shell emulator
Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
198
internal/shell/bash/bash_test.go
Normal file
198
internal/shell/bash/bash_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
type rwCloser struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *rwCloser) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFormatPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
cwd string
|
||||
want string
|
||||
}{
|
||||
{"/root", "root@host:~# "},
|
||||
{"/root/sub", "root@host:~/sub# "},
|
||||
{"/tmp", "root@host:/tmp# "},
|
||||
{"/", "root@host:/# "},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
state := &shellState{cwd: tt.cwd, username: "root", hostname: "host"}
|
||||
got := formatPrompt(state)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatPrompt(cwd=%q) = %q, want %q", tt.cwd, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineEnter(t *testing.T) {
|
||||
input := bytes.NewBufferString("hello\r")
|
||||
var output bytes.Buffer
|
||||
rw := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}{input, &output}
|
||||
|
||||
ctx := context.Background()
|
||||
line, err := readLine(ctx, rw)
|
||||
if err != nil {
|
||||
t.Fatalf("readLine: %v", err)
|
||||
}
|
||||
if line != "hello" {
|
||||
t.Errorf("line = %q, want %q", line, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineBackspace(t *testing.T) {
|
||||
// Type "helo", backspace, then "lo\r"
|
||||
input := bytes.NewBuffer([]byte{'h', 'e', 'l', 'o', 127, 'l', 'o', '\r'})
|
||||
var output bytes.Buffer
|
||||
rw := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}{input, &output}
|
||||
|
||||
ctx := context.Background()
|
||||
line, err := readLine(ctx, rw)
|
||||
if err != nil {
|
||||
t.Fatalf("readLine: %v", err)
|
||||
}
|
||||
if line != "hello" {
|
||||
t.Errorf("line = %q, want %q", line, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineCtrlC(t *testing.T) {
|
||||
input := bytes.NewBuffer([]byte("partial\x03"))
|
||||
var output bytes.Buffer
|
||||
rw := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}{input, &output}
|
||||
|
||||
ctx := context.Background()
|
||||
line, err := readLine(ctx, rw)
|
||||
if err != nil {
|
||||
t.Fatalf("readLine: %v", err)
|
||||
}
|
||||
if line != "" {
|
||||
t.Errorf("line after Ctrl+C = %q, want empty", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineCtrlD(t *testing.T) {
|
||||
input := bytes.NewBuffer([]byte{4}) // Ctrl+D on empty line
|
||||
var output bytes.Buffer
|
||||
rw := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}{input, &output}
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := readLine(ctx, rw)
|
||||
if err != io.EOF {
|
||||
t.Fatalf("expected io.EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashShellHandle(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "bash")
|
||||
|
||||
sess := &shell.SessionContext{
|
||||
SessionID: sessID,
|
||||
Username: "root",
|
||||
Store: store,
|
||||
CommonConfig: shell.ShellCommonConfig{
|
||||
Hostname: "testhost",
|
||||
Banner: "Welcome to Ubuntu 22.04.3 LTS\r\n\r\n",
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate typing commands followed by "exit\r"
|
||||
commands := "pwd\rwhoami\rexit\r"
|
||||
clientInput := bytes.NewBufferString(commands)
|
||||
var clientOutput bytes.Buffer
|
||||
rw := &rwCloser{
|
||||
Reader: clientInput,
|
||||
Writer: &clientOutput,
|
||||
}
|
||||
|
||||
sh := NewBashShell()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sh.Handle(ctx, sess, rw)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
output := clientOutput.String()
|
||||
|
||||
// Should contain banner.
|
||||
if !strings.Contains(output, "Welcome to Ubuntu") {
|
||||
t.Error("output should contain banner")
|
||||
}
|
||||
|
||||
// Should contain prompt with hostname.
|
||||
if !strings.Contains(output, "root@testhost") {
|
||||
t.Errorf("output should contain prompt, got: %s", output)
|
||||
}
|
||||
|
||||
// Check session logs were recorded.
|
||||
if len(store.SessionLogs) < 2 {
|
||||
t.Errorf("expected at least 2 session logs, got %d", len(store.SessionLogs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashShellFakeUser(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "attacker", "bash")
|
||||
|
||||
sess := &shell.SessionContext{
|
||||
SessionID: sessID,
|
||||
Username: "attacker",
|
||||
Store: store,
|
||||
CommonConfig: shell.ShellCommonConfig{
|
||||
Hostname: "testhost",
|
||||
FakeUser: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
commands := "whoami\rexit\r"
|
||||
clientInput := bytes.NewBufferString(commands)
|
||||
var clientOutput bytes.Buffer
|
||||
rw := &rwCloser{
|
||||
Reader: clientInput,
|
||||
Writer: &clientOutput,
|
||||
}
|
||||
|
||||
sh := NewBashShell()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sh.Handle(ctx, sess, rw)
|
||||
|
||||
output := clientOutput.String()
|
||||
if !strings.Contains(output, "admin") {
|
||||
t.Errorf("output should contain fake user 'admin', got: %s", output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user