Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions cmd/rune/install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
)

func TestRunInstall_JSONHappyPath(t *testing.T) {
saved := manifestURL
manifestURL = ""
defer func() { manifestURL = saved }()

dir := t.TempDir()
t.Setenv("RUNE_HOME", filepath.Join(dir, "rune"))
t.Setenv("RUNED_HOME", filepath.Join(dir, "runed"))
t.Setenv("RUNE_MANIFEST", "")

runed := []byte("runed-bytes")
mcp := []byte("rune-mcp-bytes")
sha := func(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }

mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
m := map[string]any{
"version": 1,
"rune_mcp_version": "v0.1.0-test",
"runed_version": "v0.1.0-test",
"platforms": map[string]any{
bootstrap.PlatformTuple(): map[string]any{
"runed": map[string]any{"url": srv.URL + "/runed", "sha256": sha(runed), "size": len(runed)},
"rune_mcp": map[string]any{"url": srv.URL + "/rune-mcp", "sha256": sha(mcp), "size": len(mcp)},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(m)
})

serve := func(b []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b)))
_, _ = w.Write(b)
}
}

mux.HandleFunc("/runed", serve(runed))
mux.HandleFunc("/rune-mcp", serve(mcp))
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)

var stdout, stderr bytes.Buffer
code := runInstall(context.Background(), []string{"--json", "--manifest-url", srv.URL + "/manifest.json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%q stdout=%q", code, stderr.String(), stdout.String())
}

dec := json.NewDecoder(&stdout)
var sawLog, sawSummary bool
for dec.More() {
var ev jsonEvent
if err := dec.Decode(&ev); err != nil {
t.Fatalf("stdout is not a clean JSON: %v", err)
}

switch ev.Event {
case "log":
sawLog = true
case "summary":
sawSummary = true
if ev.Error != "" {
t.Errorf("success summary should carry no error; got %q", ev.Error)
}
if ev.Result == nil || !ev.Result.OK {
t.Errorf("summary Result should be OK; got %+v", ev.Result)
}
}
}

if !sawLog {
t.Error("expected at least one log event in --json output")
}
if !sawSummary {
t.Error("expected a terminal summary event in --json output")
}
}
59 changes: 59 additions & 0 deletions cmd/rune/mcpserver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"context"
"os"
"path/filepath"
"testing"
"time"
)

func TestWaitForFile_AlreadyPresent(t *testing.T) {
p := filepath.Join(t.TempDir(), "rune-mcp")
if err := os.WriteFile(p, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}

if !waitForFile(context.Background(), p, time.Second) {
t.Error("want true when the file already exists")
}
}

func TestWaitForFile_CreatedAfterCheck(t *testing.T) {
p := filepath.Join(t.TempDir(), "rune-mcp")
go func() {
time.Sleep(100 * time.Millisecond) // file created after initial check
_ = os.WriteFile(p, []byte("x"), 0o755)
}()

if !waitForFile(context.Background(), p, 3*time.Second) {
t.Error("want true once the file appears after initial check-up")
}
}

func TestWaitForFile_CtxCancel(t *testing.T) {
p := filepath.Join(t.TempDir(), "never")
ctx, cancel := context.WithCancel(context.Background())
cancel()

start := time.Now()
if waitForFile(ctx, p, 5*time.Second) {
t.Error("want false when ctx is already cancelled")
}

if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("ctx cancel should return promptly, not wait out the timeout; took %s", elapsed)
}
}

func TestWaitForFile_Timeout(t *testing.T) {
p := filepath.Join(t.TempDir(), "never")
start := time.Now()
if waitForFile(context.Background(), p, 200*time.Millisecond) {
t.Error("want false on timeout when the file never appears")
}

if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
t.Errorf("returned %s before the 200ms timeout", elapsed)
}
}
40 changes: 40 additions & 0 deletions internal/bootstrap/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,43 @@ func TestExtractTarball_RejectsAbsolutePath(t *testing.T) {
t.Fatal("expected error for absolute entry path, got nil")
}
}

func TestExtractTarball_SkipSymlink(t *testing.T) {
dir := t.TempDir()
tarPath := filepath.Join(dir, "symlink.tar.gz")

var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
reg := []byte("REAL")

if err := tw.WriteHeader(&tar.Header{Name: "runed", Mode: 0o755, Size: int64(len(reg)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("tar header (reg): %v", err)
}
if _, err := tw.Write(reg); err != nil {
t.Fatalf("tar write: %v", err)
}
if err := tw.WriteHeader(&tar.Header{Name: "malicious", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}); err != nil {
t.Fatalf("tar header (symlink): %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("tar close: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gz close: %v", err)
}
if err := os.WriteFile(tarPath, buf.Bytes(), 0o600); err != nil {
t.Fatalf("write tarball: %v", err)
}

dest := filepath.Join(dir, "out")
if err := ExtractTarball(tarPath, dest); err != nil {
t.Fatalf("ExtractTarball should skip symlinks without error: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "runed")); err != nil {
t.Errorf("regular file should still extract alongside a skipped symlink: %v", err)
}
if _, err := os.Lstat(filepath.Join(dest, "malicious")); !os.IsNotExist(err) {
t.Errorf("symlink entry must be skipped, not created; lstat err=%v", err)
}
}
73 changes: 73 additions & 0 deletions internal/bootstrap/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,76 @@ func TestInstall_ChecksumMismatch_PartialFailure(t *testing.T) {
t.Errorf("rune-mcp should not exist after checksum failure (err=%v)", err)
}
}

func tarGz(t *testing.T, name string, body []byte) []byte {
t.Helper()
p := filepath.Join(t.TempDir(), "artifact.tar.gz")

makeTarball(t, p, map[string]struct {
body []byte
mode int64
}{
name: {body: body, mode: 0o755},
})

b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read tarball: %v", err)
}

return b
}

func TestInstall_TarballExtract(t *testing.T) {
setRealms(t)
paths, err := Resolve()
if err != nil {
t.Fatalf("Resolve: %v", err)
}

runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED"))
mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP"))

var srv *httptest.Server
mux := http.NewServeMux()

mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) {
m := map[string]any{
"version": 1,
"rune_mcp_version": "v0.1.0-test",
"runed_version": "v0.1.0-test",
"platforms": map[string]any{
PlatformTuple(): map[string]any{
"runed": map[string]any{"url": srv.URL + "/runed.tar.gz", "sha256": sha256Hex(runedTar), "size": len(runedTar), "extract": "tar.gz"},
"rune_mcp": map[string]any{"url": srv.URL + "/rune-mcp.tar.gz", "sha256": sha256Hex(mcpTar), "size": len(mcpTar), "extract": "tar.gz"},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(m)
})
mux.HandleFunc("/runed.tar.gz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(runedTar) })
mux.HandleFunc("/rune-mcp.tar.gz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(mcpTar) })

srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)

r, err := Install(context.Background(), InstallOptions{ManifestURL: srv.URL + "/manifest.json"})
if err != nil {
t.Fatalf("Install (tar.gz): %v", err)
}
if !r.OK {
t.Errorf("Result.OK = false, want true (r=%+v)", r)
}

for _, p := range []string{paths.RunedBinary, paths.RuneMCPBinary} {
info, statErr := os.Stat(p)
if statErr != nil {
t.Errorf("extracted binary missing at %s: %v", p, statErr)
continue
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("%s is not executable: mode=%v", p, info.Mode())
}
}
}
6 changes: 6 additions & 0 deletions internal/bootstrap/lock_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func TestAcquireInstallLock_TimeOut(t *testing.T) {
if !strings.Contains(err.Error(), "another install in progress") {
t.Errorf("error should mention 'another install in progress'; got %v", err)
}
if !errors.Is(err, ErrInstallInProgress) {
t.Errorf("timeout error should wrap ErrInstallInProgress; got %v", err)
}

if elapsed < 80*time.Millisecond {
t.Errorf("returned too quickly: %s", elapsed)
Expand Down Expand Up @@ -80,6 +83,9 @@ func TestAcquireInstallLock_CtxCancel(t *testing.T) {
if !errors.Is(gotErr, context.Canceled) {
t.Errorf("want context.Canceled, got %v", gotErr)
}
if !errors.Is(gotErr, ErrInstallInProgress) {
t.Errorf("context cancelled during waiting lock should be wrapped with ErrInstallInProgress; got %v", gotErr)
}
if elapsed > 1*time.Second {
t.Errorf("ctx cancel should be honored quickly; took %s", elapsed)
}
Expand Down
35 changes: 35 additions & 0 deletions internal/supervisor/supervisor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ func fakeRuned(behavior string) {
os.Exit(1)
}
os.Exit(0)
case "ignore_sigterm": // deligate SIGTERM to SIGKILL
signal.Ignore(syscall.SIGTERM, syscall.SIGINT)
time.Sleep(30 * time.Second)
os.Exit(0)
default:
os.Exit(99)
}
Expand Down Expand Up @@ -155,3 +159,34 @@ func TestWatcher_ContextCancelTriggersShutdown(t *testing.T) {
t.Errorf("runWatcher returned %v; want nil on graceful shutdown", v)
}
}

func TestWatcher_EscalateSIGKILL(t *testing.T) {
t.Setenv(fakeRunedEnv, "ignore_sigterm")
cfg := testWatcherConfig(t)
cfg.ShutdownGrace = 300 * time.Millisecond

ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runWatcher(ctx, cfg) }()

time.Sleep(400 * time.Millisecond)
start := time.Now()
cancel()

select {
case err := <-done:
elapsed := time.Since(start)
if err != nil {
t.Errorf("runWatcher = %v; want nil after SIGKILL escalation", err)
}

if elapsed < cfg.ShutdownGrace {
t.Errorf("returned in %s (< grace %s); SIGTERM should have been ignored", elapsed, cfg.ShutdownGrace)
}
if elapsed > cfg.ShutdownGrace+3*time.Second {
t.Errorf("escalation too slow: %s", elapsed)
}
case <-time.After(8 * time.Second):
t.Fatal("runWatcher hung - SIGKILL escalation did not fire")
}
}