diff --git a/cmd/rune/install_test.go b/cmd/rune/install_test.go new file mode 100644 index 0000000..6b47f1c --- /dev/null +++ b/cmd/rune/install_test.go @@ -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") + } +} diff --git a/cmd/rune/mcpserver_test.go b/cmd/rune/mcpserver_test.go new file mode 100644 index 0000000..4afb9ea --- /dev/null +++ b/cmd/rune/mcpserver_test.go @@ -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) + } +} diff --git a/internal/bootstrap/extract_test.go b/internal/bootstrap/extract_test.go index d6d6976..9d0f062 100644 --- a/internal/bootstrap/extract_test.go +++ b/internal/bootstrap/extract_test.go @@ -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) + } +} diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go index ee86ec2..c6233ec 100644 --- a/internal/bootstrap/install_test.go +++ b/internal/bootstrap/install_test.go @@ -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()) + } + } +} diff --git a/internal/bootstrap/lock_unix_test.go b/internal/bootstrap/lock_unix_test.go index d42068a..14fc97f 100644 --- a/internal/bootstrap/lock_unix_test.go +++ b/internal/bootstrap/lock_unix_test.go @@ -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) @@ -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) } diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index a64f19f..4cfc71d 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -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) } @@ -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") + } +}