//go:build linux // Package maintenanceexec supplies process and pipe mechanics. Its result is // not an approval, database-compatibility receipt or permission to resume. package maintenanceexec import ( "context" "crypto/sha256" "encoding/hex" "io" "os" "os/exec" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/maintenance" "golang.org/x/sys/unix" ) const stopMargin = 2 * time.Second type containment interface { Start(*exec.Cmd) error Populated() (bool, error) KillAndEmpty(context.Context) error } type ownership interface { SafeUntil() (time.Time, error) Lost() <-chan struct{} } // Run requires concrete retained containment and lease handles. The caller // must already hold the lifecycle fence, checked approval and verified release // and environment snapshots. This package exposes no HTTP or CLI entry point. // Artifact, containment and lease ownership remain with the caller on return. func Run(ctx context.Context, group *cgroup.Prepared, held *lease.Held, artifact *os.File, launch maintenance.LaunchContext, input maintenance.Input, environment []string) ([]byte, error) { if group == nil || held == nil { return nil, ErrRefused } return run(ctx, group, held, artifact, launch, input, environment) } func run(ctx context.Context, group containment, held ownership, artifact *os.File, launch maintenance.LaunchContext, input maintenance.Input, environment []string) (result []byte, err error) { if ctx == nil || ctx.Err() != nil || artifact == nil || launch.Version != 1 || len(input.Arguments) > maintenance.MaxArguments || len(input.Stdin) > maintenance.MaxInputBytes { return nil, ErrRefused } input.Arguments = append([]string(nil), input.Arguments...) input.Stdin = append([]byte(nil), input.Stdin...) defer clear(input.Stdin) raw, e := maintenance.EncodeLaunch(launch) if e != nil { return nil, ErrRefused } defer clear(raw) frame, e := maintenance.EncodeInput(input) if e != nil { return nil, ErrRefused } clear(frame) sum := sha256.Sum256(input.Stdin) if hex.EncodeToString(sum[:]) != launch.InputSHA256 { return nil, ErrRefused } executionDeadline, _ := time.Parse(time.RFC3339Nano, launch.ExecutionDeadline) until, e := held.SafeUntil() if e != nil { return nil, ErrRefused } if !time.Now().Add(stopMargin).Before(until) || !time.Now().Add(stopMargin).Before(executionDeadline) { return nil, ErrRefused } hashDeadline := until.Add(-stopMargin) if executionDeadline.Add(-stopMargin).Before(hashDeadline) { hashDeadline = executionDeadline.Add(-stopMargin) } hashCtx, cancelHash := context.WithDeadline(ctx, hashDeadline) e = checkArtifact(hashCtx, artifact, launch.ArtifactDigest) cancelHash() if e != nil { return nil, ErrRefused } pipes, e := newPipes() if e != nil { return nil, ErrRefused } defer pipes.close() null, e := os.OpenFile(os.DevNull, os.O_WRONLY, 0) if e != nil { return nil, ErrRefused } defer null.Close() cmd := exec.Command("/proc/self/fd/4", input.Arguments...) cmd.Env = append([]string{}, environment...) cmd.Dir = "/" cmd.Stdin = pipes.inR cmd.Stdout = pipes.outW cmd.Stderr = null cmd.ExtraFiles = []*os.File{pipes.contextR, artifact} if until, e = held.SafeUntil(); e != nil || ctx.Err() != nil || !time.Now().Add(stopMargin).Before(until) || !time.Now().Add(stopMargin).Before(executionDeadline) { return nil, ErrRefused } if e = group.Start(cmd); e != nil { stopErr := stop(group, until) if stopErr != nil { return nil, ErrUncertain } return nil, ErrRefused } pipes.inR.Close() pipes.outW.Close() pipes.contextR.Close() cleanupDone := false defer func() { if cleanupDone { return } pipes.close() if stop(group, until) != nil { err = ErrUncertain result = nil } }() events := make(chan pipeEvent, 4) go writePipe(pipes.inW, append([]byte(nil), input.Stdin...), "input", events) go writePipe(pipes.contextW, append([]byte(nil), raw...), "context", events) go func() { b, e := io.ReadAll(io.LimitReader(pipes.outR, maintenance.MaxResultBytes+1)) if len(b) > maintenance.MaxResultBytes { clear(b) b = nil e = ErrRefused } events <- pipeEvent{"output", b, e} }() go func() { events <- pipeEvent{"wait", nil, cmd.Wait()} }() tick := time.NewTicker(10 * time.Millisecond) defer tick.Stop() left := 4 failed := false waited := false for left > 0 { select { case event := <-events: left-- if event.kind == "wait" { waited = true } if event.err != nil { failed = true } if event.kind == "output" { result = event.data } case <-ctx.Done(): failed = true case <-held.Lost(): failed = true case <-tick.C: next, e := held.SafeUntil() if e != nil { failed = true } else { until = next } now := time.Now() if !now.Add(stopMargin).Before(until) || !now.Add(stopMargin).Before(executionDeadline) { failed = true } } if failed || waited { if waited { populated, e := group.Populated() if e != nil || populated { failed = true } } e := stop(group, until) if e != nil { clear(result) return nil, ErrUncertain } cleanupDone = true // Closing parent pipe endpoints also releases blocked I/O after the // kernel has proved the child tree empty. if failed { pipes.close() } for left > 0 { select { case event := <-events: left-- if event.err != nil { failed = true } if event.kind == "output" { result = event.data } case <-time.After(time.Until(until)): clear(result) return nil, ErrUncertain } } break } } if !cleanupDone { e := stop(group, until) if e != nil { clear(result) return nil, ErrUncertain } cleanupDone = true } if _, e = held.SafeUntil(); e != nil || ctx.Err() != nil || !time.Now().Before(executionDeadline) { failed = true } if failed || len(result) == 0 { clear(result) return nil, ErrRefused } return result, nil } func stop(group containment, until time.Time) error { deadline := time.Now().Add(stopMargin) if until.After(deadline) { deadline = until } ctx, cancel := context.WithDeadline(context.Background(), deadline) defer cancel() err := group.KillAndEmpty(ctx) if err != nil || !time.Now().Before(until) { return ErrUncertain } return nil } type pipeEvent struct { kind string data []byte err error } func writePipe(f *os.File, data []byte, kind string, events chan<- pipeEvent) { defer clear(data) _, err := f.Write(data) closeErr := f.Close() if err == nil { err = closeErr } events <- pipeEvent{kind: kind, err: err} } func checkArtifact(ctx context.Context, f *os.File, want string) error { flags, e := unix.FcntlInt(f.Fd(), unix.F_GETFL, 0) if e != nil || flags&unix.O_ACCMODE != unix.O_RDONLY { return ErrRefused } st, e := f.Stat() if e != nil || !st.Mode().IsRegular() || st.Size() <= 0 { return ErrRefused } hash := sha256.New() reader := io.NewSectionReader(f, 0, st.Size()) buf := make([]byte, 64<<10) for { if ctx.Err() != nil { return ErrRefused } n, e := reader.Read(buf) if n > 0 { hash.Write(buf[:n]) } if e == io.EOF { break } if e != nil { return ErrRefused } } after, e := f.Stat() if e != nil || after.Size() != st.Size() || !os.SameFile(st, after) || hex.EncodeToString(hash.Sum(nil)) != want { return ErrRefused } return nil }