Files
run/runtime/dependencies.go
T

566 lines
20 KiB
Go

package runtime
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"browser.local/run/protocol"
)
const (
maxDependencyDownloadBytes = int64(512 * 1024 * 1024)
dependencyCommandTimeout = 10 * time.Minute
)
var (
dependencyTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+@/-]{0,119}$`)
dependencyVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+~-]{0,79}$`)
)
type DependencyDownloader interface {
Download(context.Context, string, string, int64) (int64, string, error)
}
type HTTPDependencyDownloader struct {
Client *http.Client
}
func (downloader HTTPDependencyDownloader) Download(ctx context.Context, sourceURL, destination string, maxBytes int64) (int64, string, error) {
parsed, err := validateDependencyDownloadURL(sourceURL)
if err != nil {
return 0, "", err
}
client := downloader.Client
if client == nil {
client = &http.Client{Timeout: 10 * time.Minute, CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("dependency download redirect limit exceeded")
}
_, err := validateDependencyDownloadURL(request.URL.String())
return err
}}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return 0, "", err
}
response, err := client.Do(request)
if err != nil {
return 0, "", err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, "", fmt.Errorf("dependency download returned status %d", response.StatusCode)
}
if response.ContentLength > maxBytes {
return 0, "", fmt.Errorf("dependency download exceeds size limit")
}
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return 0, "", err
}
temporary := destination + ".partial"
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return 0, "", err
}
remove := true
defer func() {
_ = file.Close()
if remove {
_ = os.Remove(temporary)
}
}()
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(response.Body, maxBytes+1))
if err != nil {
return 0, "", err
}
if written > maxBytes {
return 0, "", fmt.Errorf("dependency download exceeds size limit")
}
if err := file.Sync(); err != nil {
return 0, "", err
}
if err := file.Close(); err != nil {
return 0, "", err
}
if err := os.Rename(temporary, destination); err != nil {
return 0, "", err
}
remove = false
return written, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
type dependencyJournal struct {
Version int `json:"version"`
JobID string `json:"jobId"`
Attempt int `json:"attempt"`
PlanDigest string `json:"planDigest"`
CompletedSteps []int `json:"completedSteps,omitempty"`
State string `json:"state"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (worker *Worker) executeDependencyJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
return lifecycleFailure("unsafe_dependency_job", err.Error())
}
runState, err := worker.registeredState()
if err != nil {
return lifecycleFailure("dependency_unregistered", "Run worker is not registered")
}
input, err := worker.client.GetDependencyExecutionInput(ctx, protocol.DependencyExecutionInputRequest{RunEndpointID: runState.RunEndpointID, SessionToken: runState.SessionToken, JobID: assignment.JobID, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt})
if err != nil {
return lifecycleFailure("dependency_input_failed", "could not load fenced dependency input")
}
if err := validateDependencyInput(assignment, input); err != nil {
return lifecycleFailure("unsafe_dependency_input", err.Error())
}
journalPath := filepath.Join(worker.cfg.WorkspaceRoot, "dependency-journals", safeWorkspaceName(assignment.JobID)+".json")
journal, err := loadDependencyJournal(journalPath, assignment, input.PlanDigest)
if err != nil {
return lifecycleFailure("dependency_journal_failed", err.Error())
}
if assignment.Capability == protocol.RunCapabilityDependenciesCheck {
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings)
if probeErr != nil {
if errors.Is(probeErr, context.Canceled) || errors.Is(probeErr, context.DeadlineExceeded) {
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "dependency probe cancelled"}, Message: "dependency probe cancelled", ErrorCode: "dependency_probe_cancelled"}
}
return lifecycleFailure("dependency_probe_failed", probeErr.Error())
}
journal.State = state
journal.UpdatedAt = time.Now().UTC()
if err := persistDependencyJournal(journalPath, journal); err != nil {
return lifecycleFailure("dependency_journal_failed", err.Error())
}
return dependencySuccess(assignment, input, state, evidence, 0)
}
completed := map[int]bool{}
for _, index := range journal.CompletedSteps {
completed[index] = true
}
for index, step := range input.Plan.Steps {
if completed[index] {
continue
}
if cancelled, ok := checkContextCancelled(ctx, "dependency installation cancelled", "dependency_install_cancelled"); ok {
return cancelled
}
if err := worker.executor.runDependencyInstallStep(ctx, assignment, input, step, index); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "dependency installation cancelled"}, Message: "dependency installation cancelled", ErrorCode: "dependency_install_cancelled"}
}
return lifecycleFailure("dependency_install_failed", err.Error())
}
journal.CompletedSteps = append(journal.CompletedSteps, index)
journal.State = "installing"
journal.UpdatedAt = time.Now().UTC()
if err := persistDependencyJournal(journalPath, journal); err != nil {
return lifecycleFailure("dependency_journal_failed", err.Error())
}
}
state, evidence, probeErr := worker.executor.runDependencyProbe(ctx, input.Probe, input.Bindings)
if probeErr != nil {
return lifecycleFailure("dependency_verify_failed", probeErr.Error())
}
if state != "present" {
return lifecycleFailure("dependency_verify_missing", "dependency remains missing after install plan")
}
journal.State = "present"
journal.UpdatedAt = time.Now().UTC()
if err := persistDependencyJournal(journalPath, journal); err != nil {
return lifecycleFailure("dependency_journal_failed", err.Error())
}
return dependencySuccess(assignment, input, "present", evidence, len(journal.CompletedSteps))
}
func validateDependencyInput(assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse) error {
if input.JobID != assignment.JobID || input.ServerInstanceID != assignment.ServerInstanceID || input.RunEndpointID != assignment.RunEndpointID {
return fmt.Errorf("dependency input scope does not match job")
}
if input.TargetOS != runtime.GOOS || input.TargetArch != runtime.GOARCH {
return fmt.Errorf("dependency input target does not match Run")
}
if !validSHA256(input.PlanDigest) || !protocol.ValidLogicalFileKey(input.ProfileKey) {
return fmt.Errorf("dependency input digest or profile is unsafe")
}
if !protocol.ValidLogicalFileKey(input.Probe.Key) || !protocol.ValidLogicalFileKey(input.Probe.TargetKey) {
return fmt.Errorf("dependency probe is unsafe")
}
if assignment.Capability == protocol.RunCapabilityDependenciesCheck {
if assignment.TargetKey != "dependencies/"+input.Probe.Key || input.Plan.Key != "" {
return fmt.Errorf("dependency check declaration does not match job")
}
return nil
}
if assignment.Capability != protocol.RunCapabilityDependenciesInstall || assignment.TargetKey != "dependencies/install/"+input.Plan.Key || !protocol.ValidLogicalFileKey(input.Plan.Key) || len(input.Plan.Steps) == 0 || len(input.Plan.Steps) > 64 {
return fmt.Errorf("dependency install declaration does not match job")
}
for _, step := range input.Plan.Steps {
if !validDependencyInstallStepType(step.Type) {
return fmt.Errorf("dependency install step type is unsupported")
}
}
return nil
}
func validDependencyInstallStepType(value string) bool {
switch value {
case "package", "verified-download", "manual":
return true
default:
return false
}
}
func (executor LifecycleExecutor) runDependencyProbe(ctx context.Context, probe protocol.DependencyProbe, bindings map[string]string) (string, string, error) {
target := strings.TrimSpace(bindings[probe.TargetKey])
if target == "" {
target = probe.TargetKey
}
switch probe.Kind {
case "file.exists", "steam.app":
info, err := os.Lstat(target)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "missing", "declared target is not present", nil
}
return "", "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", "", fmt.Errorf("dependency target cannot be a symlink")
}
return "present", "declared target is present", nil
case "package.installed":
if err := validateDependencyExecutable(target); err != nil {
return "", "", err
}
if filepath.IsAbs(target) {
if _, err := os.Stat(target); err != nil {
return "missing", "declared package executable is not present", nil
}
} else if _, err := exec.LookPath(target); err != nil {
return "missing", "declared package executable is not present", nil
}
return "present", "declared package executable is present", nil
case "command.version", "java.version", "docker.available":
if err := validateDependencyExecutable(target); err != nil {
return "", "", err
}
args := []string{"--version"}
if probe.Kind == "java.version" {
args = []string{"-version"}
}
result, err := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{target}, args...), Timeout: 30 * time.Second, Capability: "dependency.probe", Action: probe.Kind})
if err != nil || result.ExitCode != 0 {
if ctx.Err() != nil {
return "", "", ctx.Err()
}
return "missing", "declared executable is not available", nil
}
version := dependencyVersionEvidence(processOutputEvidence(result.Stdout, result.Stderr))
if probe.MinimumVersion != "" && !dependencyVersionAtLeast(version, probe.MinimumVersion) {
return "missing", "declared executable version is below minimum", nil
}
return "present", version, nil
case "service.exists":
if !dependencyTokenPattern.MatchString(target) {
return "", "", fmt.Errorf("dependency service target is unsafe")
}
name, args := serviceProbeCommand(runtime.GOOS, target)
if name == "" {
return "", "", fmt.Errorf("service probe is unsupported on this platform")
}
result, err := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{name}, args...), Timeout: 30 * time.Second, Capability: "dependency.probe", Action: probe.Kind})
if err != nil || result.ExitCode != 0 {
if ctx.Err() != nil {
return "", "", ctx.Err()
}
return "missing", "declared service is not present", nil
}
return "present", "declared service is present", nil
default:
return "", "", fmt.Errorf("dependency probe kind is unsupported")
}
}
func (executor LifecycleExecutor) runDependencyInstallStep(ctx context.Context, assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse, step protocol.DependencyInstallStep, index int) error {
if !protocol.ValidLogicalFileKey(step.TargetKey) {
return fmt.Errorf("dependency install target is unsafe")
}
switch step.Type {
case "package":
name, args, err := packageInstallCommand(step.PackageManager, step.PackageName, step.Version)
if err != nil {
return err
}
result, runErr := executor.dependencyRunner.Run(ctx, ProcessCommand{Args: append([]string{name}, args...), Timeout: dependencyCommandTimeout, JobID: assignment.JobID, Capability: assignment.Capability, Action: "dependency.package"})
if runErr != nil || result.ExitCode != 0 {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("typed package adapter failed")
}
return nil
case "verified-download":
if !validSHA256(step.Checksum) {
return fmt.Errorf("verified download checksum is required")
}
if _, err := validateDependencyDownloadURL(step.DownloadRef); err != nil {
return err
}
destination := filepath.Join(executor.workspaceRoot, "dependency-files", safeWorkspaceName(assignment.ServerInstanceID), safeWorkspaceName(step.TargetKey))
size, checksum, err := executor.dependencyDownloader.Download(ctx, step.DownloadRef, destination, maxDependencyDownloadBytes)
if err != nil {
return err
}
if size <= 0 || checksum != strings.ToLower(step.Checksum) {
_ = os.Remove(destination)
return fmt.Errorf("verified dependency download checksum mismatch")
}
return os.Chmod(destination, 0o700)
case "manual":
return fmt.Errorf("manual dependency step requires operator action")
default:
return fmt.Errorf("dependency install step type is unsupported")
}
}
func packageInstallCommand(manager, packageName, version string) (string, []string, error) {
if !dependencyTokenPattern.MatchString(packageName) || version != "" && !dependencyVersionPattern.MatchString(version) {
return "", nil, fmt.Errorf("package name or version is unsafe")
}
spec := packageName
switch manager {
case "apt":
if version != "" {
spec += "=" + version
}
return "apt-get", []string{"install", "-y", "--no-install-recommends", spec}, nil
case "yum", "dnf":
if version != "" {
spec += "-" + version
}
return manager, []string{"install", "-y", spec}, nil
case "pacman":
return "pacman", []string{"-S", "--noconfirm", spec}, nil
case "zypper":
return "zypper", []string{"--non-interactive", "install", spec}, nil
case "brew":
if version != "" {
spec += "@" + version
}
return "brew", []string{"install", spec}, nil
case "winget":
args := []string{"install", "--id", packageName, "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements"}
if version != "" {
args = append(args, "--version", version)
}
return "winget", args, nil
case "choco":
args := []string{"install", packageName, "-y", "--no-progress"}
if version != "" {
args = append(args, "--version", version)
}
return "choco", args, nil
case "scoop":
if version != "" {
spec += "@" + version
}
return "scoop", []string{"install", spec}, nil
default:
return "", nil, fmt.Errorf("package manager is unsupported")
}
}
func validateDependencyExecutable(target string) error {
if strings.TrimSpace(target) != target || target == "" || strings.ContainsAny(target, "\r\n\x00") || containsUnsafeRuntimeText(target) {
return fmt.Errorf("dependency executable target is unsafe")
}
if filepath.IsAbs(target) {
info, err := os.Lstat(target)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
return fmt.Errorf("dependency executable target is not a regular executable")
}
return nil
}
if !commandNamePattern.MatchString(target) {
return fmt.Errorf("dependency executable name is unsafe")
}
if _, forbidden := disallowedExecutables[strings.ToLower(target)]; forbidden {
return fmt.Errorf("dependency executable cannot be a shell")
}
return nil
}
func validateDependencyDownloadURL(raw string) (*url.URL, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(raw))
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" {
return nil, fmt.Errorf("dependency download URL is not approved")
}
host := strings.ToLower(parsed.Hostname())
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return nil, fmt.Errorf("dependency download host is not approved")
}
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) {
return nil, fmt.Errorf("dependency download host is not approved")
}
return parsed, nil
}
func serviceProbeCommand(targetOS, service string) (string, []string) {
switch targetOS {
case "linux":
return "systemctl", []string{"status", service, "--no-pager"}
case "windows":
return "sc", []string{"query", service}
case "darwin":
return "launchctl", []string{"print", "system/" + service}
default:
return "", nil
}
}
func dependencySuccess(assignment protocol.RunJobAssignment, input protocol.DependencyExecutionInputResponse, state, evidence string, completed int) LifecycleExecutionResult {
payload, _ := json.Marshal(protocol.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanKey: input.Plan.Key, PlanDigest: input.PlanDigest, State: state, Evidence: evidence, CompletedSteps: completed})
kind := "dependency.check"
message := "dependency probe completed"
if assignment.Capability == protocol.RunCapabilityDependenciesInstall {
kind = "dependency.install"
message = "dependency install plan completed and verified"
}
return LifecycleExecutionResult{State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: message}, ResultRef: fmt.Sprintf("artifact://jobs/%s/dependencies-result", url.PathEscape(assignment.JobID)), Message: message, ExecutionResult: protocol.RunJobExecutionResult{Kind: kind, Checksum: input.PlanDigest, Summary: message, Content: string(payload)}}
}
func processOutputEvidence(stdout string, stderr string) string {
if stdout == "" {
return stderr
}
if stderr == "" {
return stdout
}
return stdout + "\n" + stderr
}
func loadDependencyJournal(path string, assignment protocol.RunJobAssignment, digest string) (dependencyJournal, error) {
journal := dependencyJournal{Version: 1, JobID: assignment.JobID, Attempt: assignment.Attempt, PlanDigest: digest, State: "pending", UpdatedAt: time.Now().UTC()}
body, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return journal, nil
}
if err != nil {
return dependencyJournal{}, err
}
if err := json.Unmarshal(body, &journal); err != nil {
return dependencyJournal{}, fmt.Errorf("decode dependency journal: %w", err)
}
if journal.Version != 1 || journal.JobID != assignment.JobID || journal.PlanDigest != digest {
return dependencyJournal{}, fmt.Errorf("dependency journal does not match immutable plan")
}
if journal.Attempt > assignment.Attempt {
return dependencyJournal{}, fmt.Errorf("dependency journal attempt is newer than assignment")
}
journal.Attempt = assignment.Attempt
return journal, nil
}
func persistDependencyJournal(path string, journal dependencyJournal) error {
body, err := json.MarshalIndent(journal, "", " ")
if err != nil {
return err
}
return writeRuntimeAtomicFile(path, body, 0o600)
}
func writeRuntimeAtomicFile(path string, body []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
temporary := path + ".tmp"
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := file.Write(body); err != nil {
_ = file.Close()
_ = os.Remove(temporary)
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
_ = os.Remove(temporary)
return err
}
if err := file.Close(); err != nil {
_ = os.Remove(temporary)
return err
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return err
}
return os.Chmod(path, mode)
}
func validSHA256(value string) bool {
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
return false
}
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
return err == nil
}
func dependencyVersionEvidence(output string) string {
if output == "" {
return "version available"
}
return output
}
func dependencyVersionAtLeast(actual, minimum string) bool {
numbers := func(value string) []int {
parts := regexp.MustCompile(`[0-9]+`).FindAllString(value, -1)
out := make([]int, len(parts))
for i, part := range parts {
out[i], _ = strconv.Atoi(part)
}
return out
}
a, b := numbers(actual), numbers(minimum)
for i := 0; i < len(a) || i < len(b); i++ {
av, bv := 0, 0
if i < len(a) {
av = a[i]
}
if i < len(b) {
bv = b[i]
}
if av != bv {
return av > bv
}
}
return len(a) > 0
}