190 lines
8.5 KiB
Go
190 lines
8.5 KiB
Go
package runtime
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
func TestRunSelfUpdateResumesPartialDownloadAndRejectsChecksum(t *testing.T) {
|
|
assignment, input, payload := selfUpdateTestFixture(t)
|
|
client := newFakeWorkerClient()
|
|
client.updateInput = input
|
|
client.updatePayload = payload
|
|
cfg := workerTestConfig(t)
|
|
transactionRoot := filepath.Join(cfg.WorkspaceRoot, "self-updates", safeWorkspaceName(assignment.JobID))
|
|
if err := os.MkdirAll(transactionRoot, 0o700); err != nil {
|
|
t.Fatalf("create transaction root: %v", err)
|
|
}
|
|
partial := len(payload) / 3
|
|
if err := os.WriteFile(filepath.Join(transactionRoot, "update.archive"), payload[:partial], 0o600); err != nil {
|
|
t.Fatalf("write partial update: %v", err)
|
|
}
|
|
worker, err := NewWorker(cfg, client, WithSelfUpdateActivator(&recordingSelfUpdateActivator{}))
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
worker.state.SessionToken = "session-token"
|
|
result := worker.executeRunSelfUpdate(context.Background(), assignment)
|
|
if result.State != lifecycleResultStateSucceeded || result.ExecutionResult.Kind != "run.update.staged" || result.ActivationManifest == "" {
|
|
t.Fatalf("expected staged self-update, got %+v", result)
|
|
}
|
|
if len(client.updateChunkOffsets) == 0 || client.updateChunkOffsets[0] != int64(partial) {
|
|
t.Fatalf("expected resumable range from %d, got %+v", partial, client.updateChunkOffsets)
|
|
}
|
|
|
|
badClient := newFakeWorkerClient()
|
|
badInput := input
|
|
badInput.Checksum = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
badClient.updateInput = badInput
|
|
badClient.updatePayload = payload
|
|
badWorker, err := NewWorker(workerTestConfig(t), badClient, WithSelfUpdateActivator(&recordingSelfUpdateActivator{}))
|
|
if err != nil {
|
|
t.Fatalf("new bad checksum worker: %v", err)
|
|
}
|
|
badWorker.state.SessionToken = "session-token"
|
|
bad := badWorker.executeRunSelfUpdate(context.Background(), assignment)
|
|
if bad.State != lifecycleResultStateFailed || bad.ErrorCode != "self_update_download_failed" {
|
|
t.Fatalf("expected final checksum rejection, got %+v", bad)
|
|
}
|
|
}
|
|
|
|
func TestSelfUpdateActivationWaitsForAcceptedTerminalResult(t *testing.T) {
|
|
assignment, input, payload := selfUpdateTestFixture(t)
|
|
client := newFakeWorkerClient()
|
|
client.claimJob = assignment
|
|
client.updateInput = input
|
|
client.updatePayload = payload
|
|
client.resultErr = errors.New("stale lease")
|
|
activator := &recordingSelfUpdateActivator{}
|
|
worker, err := NewWorker(workerTestConfig(t), client, WithSelfUpdateActivator(activator))
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
if err := worker.Register(context.Background()); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
handled, err := worker.ClaimAndRunOnce(context.Background())
|
|
if !handled || err == nil {
|
|
t.Fatalf("expected rejected result error, handled=%v err=%v", handled, err)
|
|
}
|
|
if activator.manifestPath != "" {
|
|
t.Fatalf("stale terminal result must not activate update: %s", activator.manifestPath)
|
|
}
|
|
if worker.journal.ActiveCount() != 1 {
|
|
t.Fatal("staged result must remain recoverable until Platform accepts it")
|
|
}
|
|
}
|
|
|
|
func TestSelfUpdateReplacementPreservesRollbackAndRejectsTraversal(t *testing.T) {
|
|
root := t.TempDir()
|
|
current := filepath.Join(root, "run")
|
|
staged := filepath.Join(root, "staged-run")
|
|
backup := filepath.Join(root, "previous-run")
|
|
if err := os.WriteFile(current, []byte("old-run"), 0o700); err != nil {
|
|
t.Fatalf("write current: %v", err)
|
|
}
|
|
if err := os.WriteFile(staged, []byte("new-run"), 0o700); err != nil {
|
|
t.Fatalf("write staged: %v", err)
|
|
}
|
|
checksum, _, err := checksumFile(staged)
|
|
if err != nil {
|
|
t.Fatalf("checksum staged: %v", err)
|
|
}
|
|
manifest := SelfUpdateManifest{StagedExecutable: staged, CurrentExecutable: current, BackupExecutable: backup, BinaryChecksum: checksum}
|
|
if err := replaceRunExecutable(manifest); err != nil {
|
|
t.Fatalf("replace executable: %v", err)
|
|
}
|
|
if body, _ := os.ReadFile(current); string(body) != "new-run" {
|
|
t.Fatalf("expected new executable, got %q", body)
|
|
}
|
|
if err := rollbackRunExecutable(manifest); err != nil {
|
|
t.Fatalf("rollback executable: %v", err)
|
|
}
|
|
if body, _ := os.ReadFile(current); string(body) != "old-run" {
|
|
t.Fatalf("expected previous executable after rollback, got %q", body)
|
|
}
|
|
|
|
archivePath := filepath.Join(root, "unsafe.tar.gz")
|
|
var archive bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&archive)
|
|
tarWriter := tar.NewWriter(gzipWriter)
|
|
body := []byte("escape")
|
|
if err := tarWriter.WriteHeader(&tar.Header{Name: "../run", Mode: 0o700, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
|
|
t.Fatalf("write unsafe header: %v", err)
|
|
}
|
|
_, _ = tarWriter.Write(body)
|
|
_ = tarWriter.Close()
|
|
_ = gzipWriter.Close()
|
|
if err := os.WriteFile(archivePath, archive.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("write unsafe archive: %v", err)
|
|
}
|
|
if _, err := extractRunUpdateBinary(archivePath, "tar.gz", "run", filepath.Join(root, "escaped")); err == nil || !strings.Contains(err.Error(), "unsafe") {
|
|
t.Fatalf("expected traversal rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareSelfUpdateManifestRevalidatesStagedBinaryAfterRestart(t *testing.T) {
|
|
root := t.TempDir()
|
|
assignment := workerJobAssignment(protocol.RunCapabilityRunSelfUpdate)
|
|
assignment.TargetKey = "run/update"
|
|
assignment.InputRef = "artifact://artifact-run-staged"
|
|
input := protocol.RunUpdateInputResponse{JobID: assignment.JobID, ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID, ArtifactID: "artifact-run-staged", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 16, TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, PackageFormat: "tar.gz", ExecutableName: "run", TargetRelease: "release-staged", ChunkSizeBytes: 8}
|
|
manifestPath := filepath.Join(root, "manifest.json")
|
|
stagedPath := filepath.Join(root, "run.staged")
|
|
if err := os.WriteFile(stagedPath, []byte("staged-binary"), 0o700); err != nil {
|
|
t.Fatalf("write staged binary: %v", err)
|
|
}
|
|
checksum, _, err := checksumFile(stagedPath)
|
|
if err != nil {
|
|
t.Fatalf("checksum staged binary: %v", err)
|
|
}
|
|
manifest := SelfUpdateManifest{Version: selfUpdateManifestVersion, JobID: assignment.JobID, Attempt: assignment.Attempt, LeaseToken: assignment.LeaseToken, ArtifactID: input.ArtifactID, ArtifactChecksum: input.Checksum, ArtifactSizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, TargetRelease: input.TargetRelease, StagedExecutable: stagedPath, BinaryChecksum: checksum, Phase: "staged", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
|
if err := persistSelfUpdateManifest(manifestPath, manifest); err != nil {
|
|
t.Fatalf("persist staged manifest: %v", err)
|
|
}
|
|
if _, err := prepareSelfUpdateManifest(manifestPath, assignment, input, root); err != nil {
|
|
t.Fatalf("revalidate staged manifest: %v", err)
|
|
}
|
|
if err := os.WriteFile(stagedPath, []byte("tampered-binary"), 0o700); err != nil {
|
|
t.Fatalf("tamper staged binary: %v", err)
|
|
}
|
|
if _, err := prepareSelfUpdateManifest(manifestPath, assignment, input, root); err == nil || !strings.Contains(err.Error(), "checksum changed") {
|
|
t.Fatalf("expected staged checksum rejection, got %v", err)
|
|
}
|
|
manifest.StagedExecutable = filepath.Join(root, "..", "outside")
|
|
if err := persistSelfUpdateManifest(manifestPath, manifest); err != nil {
|
|
t.Fatalf("persist unsafe staged manifest: %v", err)
|
|
}
|
|
if _, err := prepareSelfUpdateManifest(manifestPath, assignment, input, root); err == nil || !strings.Contains(err.Error(), "outside") {
|
|
t.Fatalf("expected staged path rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func selfUpdateTestFixture(t *testing.T) (protocol.RunJobAssignment, protocol.RunUpdateInputResponse, []byte) {
|
|
t.Helper()
|
|
assignment := workerJobAssignment(protocol.RunCapabilityRunSelfUpdate)
|
|
assignment.TargetKey = "run/update"
|
|
assignment.InputRef = "artifact://artifact-run-latest"
|
|
assignment.State = "running"
|
|
assignment.LeaseToken = "lease-update"
|
|
assignment.Attempt = 1
|
|
executableName := "run"
|
|
if runtime.GOOS == "windows" {
|
|
executableName = "run.exe"
|
|
}
|
|
payload := []byte("self-update-test-binary")
|
|
input := protocol.RunUpdateInputResponse{JobID: assignment.JobID, ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID, ArtifactID: "artifact-run-latest", Checksum: bytesChecksum(payload), SizeBytes: int64(len(payload)), TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, PackageFormat: "raw-executable", ExecutableName: executableName, TargetRelease: "run-release-test", ChunkSizeBytes: 64}
|
|
return assignment, input, payload
|
|
}
|