Merge branch 'feat-maintenance-worker' into main
15 files changed,
+1385
−0
implementation/maintenance-worker-progress.json+25 −0internal/cgroup/strict_linux.go+13 −0internal/cgroup/strict_linux_test.go+25 −0internal/cgroup/strict_other.go+1 −0internal/maintenance/launch.go+221 −0internal/maintenance/launch_test.go+274 −0internal/maintenance/launch_utf8.go+48 −0internal/maintenance/testdata/generate-launch-vector.py+44 −0internal/maintenance/testdata/launch-v2-binding.json+1 −0internal/maintenance/testdata/launch-v2.json+29 −0internal/maintenanceexec/errors.go+6 −0internal/maintenanceexec/pipes_linux.go+33 −0internal/maintenanceexec/run_linux.go+287 −0internal/maintenanceexec/run_linux_test.go+362 −0internal/maintenanceexec/run_other.go+16 −0
CI passed — run details
diff --git a/implementation/maintenance-worker-progress.json b/implementation/maintenance-worker-progress.json| new file mode 100644 |
| index 000000000..23c22af22 |
| --- /dev/null |
| +++ b/implementation/maintenance-worker-progress.json |
| @@ -0,0 +1,25 @@ |
| +{ |
| + "scope": "Complete supported maintenance dispatch needed by Oficina provisioning; no live invocation until all approved launch gates are implemented and reviewed", |
| + "base": "006e7825", |
| + "current_slice": "Strict private launch codec and inert Linux subprocess supervisor; no worker mounting", |
| + "authority": "Decoded context is metadata only. It cannot construct a retained lease, executor, lifecycle claim, approval or trusted invocation.", |
| + "remaining": [ |
| + "Native cgroup/lease integration evidence for reviewed subprocess supervisor", |
| + "Retained worker lease/executor and closed-checkpoint preparation proof", |
| + "Home startup adapter binding signed input, image, descriptors and opened database", |
| + "Owner-reviewed prepare/approval/status/result/retry/resume CLI and console workflow", |
| + "Private encrypted request/result publication with authenticated worker provenance", |
| + "Integrated native tests and Fable review before worker mounting", |
| + "Preview owner-approved provisioning, restore drill, and public gateway cutover" |
| + ], |
| + "verification": [ |
| + "Required closed fields and exact numeric ranges", |
| + "Canonical timestamps, identifiers and binding digests", |
| + "UTF-8 and surrogate validity", |
| + "8KiB hard limit and overflow detection", |
| + "Nested v2 checkpoint evidence bindings", |
| + "No command mounting or execution authority from decoded bytes", |
| + "Focused race tests for descendants, expired-lease termination, exact private input and descriptor/environment isolation", |
| + "Fable approved codec at 01b82bde; supervisor refusal preserved and fixes awaiting re-review" |
| + ] |
| +} |
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go| index 7d365038e..4b2557507 100644 |
| --- a/internal/cgroup/strict_linux.go |
| +++ b/internal/cgroup/strict_linux.go |
| @@ -266,6 +266,19 @@ func (p *Prepared) Start(cmd *exec.Cmd) error { |
| return nil |
| } |
| +// Populated checks the retained group after the direct child has been reaped. |
| +func (p *Prepared) Populated() (bool, error) { |
| + if p == nil || p.strictPreparedState == nil { |
| + return false, ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if e := p.identityOK(); e != nil { |
| + return false, e |
| + } |
| + return strictPopulation(p.events) |
| +} |
| + |
| // KillAndEmpty proves kernel population is zero before the caller's deadline. |
| // Failure retains the pinned cleanup handle and permits another bounded attempt. |
| func (p *Prepared) KillAndEmpty(ctx context.Context) error { |
diff --git a/internal/cgroup/strict_linux_test.go b/internal/cgroup/strict_linux_test.go| index 0d6124a94..03f687a55 100644 |
| --- a/internal/cgroup/strict_linux_test.go |
| +++ b/internal/cgroup/strict_linux_test.go |
| @@ -65,6 +65,31 @@ func strictContext(t *testing.T, d time.Duration) context.Context { |
| t.Cleanup(cancel) |
| return ctx |
| } |
| + |
| +func TestStrictPopulationUsesRetainedIdentity(t *testing.T) { |
| + p := strictFixture(t) |
| + for _, populated := range []bool{false, true} { |
| + value := "populated 0\nfrozen 0\n" |
| + if populated { |
| + value = "populated 1\nfrozen 0\n" |
| + } |
| + if e := strictWrite(p.events, value); e != nil { |
| + t.Fatal(e) |
| + } |
| + if got, e := p.Populated(); e != nil || got != populated { |
| + t.Fatal("incorrect population", got, e) |
| + } |
| + } |
| + if e := os.Rename(p.dir.Name(), p.dir.Name()+".old"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := p.Populated(); e == nil { |
| + t.Fatal("replaced cgroup accepted") |
| + } |
| + if _, e := (&Prepared{}).Populated(); e == nil { |
| + t.Fatal("empty handle accepted") |
| + } |
| +} |
| func TestStrictRejectsUnavailableManagerHostAndTier(t *testing.T) { |
| for _, m := range []*Manager{nil, {root: t.TempDir()}} { |
| for _, host := range []string{"ok.example", "../outside", "Upper.example", "", "a/b"} { |
diff --git a/internal/cgroup/strict_other.go b/internal/cgroup/strict_other.go| index 0a1b0ed76..b5c10bd57 100644 |
| --- a/internal/cgroup/strict_other.go |
| +++ b/internal/cgroup/strict_other.go |
| @@ -13,5 +13,6 @@ func (m *Manager) PrepareStrict(host string, limit Limit) (*Prepared, error) { |
| return nil, ErrStrictUnsupported |
| } |
| func (p *Prepared) Start(cmd *exec.Cmd) error { return ErrStrictUnsupported } |
| +func (p *Prepared) Populated() (bool, error) { return false, ErrStrictUnsupported } |
| func (p *Prepared) KillAndEmpty(ctx context.Context) error { return ErrStrictUnsupported } |
| func (p *Prepared) Close() error { return ErrStrictUnsupported } |
diff --git a/internal/maintenance/launch.go b/internal/maintenance/launch.go| new file mode 100644 |
| index 000000000..edc48b0d9 |
| --- /dev/null |
| +++ b/internal/maintenance/launch.go |
| @@ -0,0 +1,221 @@ |
| +package maintenance |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/maintenanceguard" |
| +) |
| + |
| +const MaxLaunchBytes = 8192 |
| + |
| +var ErrLaunch = errors.New("maintenance: invalid private launch context") |
| + |
| +// LaunchContext is private transport metadata, never proof of approval, |
| +// exclusion, lease ownership or a trusted worker invocation. |
| +type LaunchContext struct { |
| + Version int `json:"version"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Host string `json:"host"` |
| + RequestID string `json:"request_id"` |
| + AttemptID string `json:"attempt_id"` |
| + RequestDigest string `json:"request_digest"` |
| + RecordIdentity string `json:"record_identity"` |
| + ArtifactDigest string `json:"artifact_digest"` |
| + EnvironmentEnvelopeDigest string `json:"environment_envelope_digest"` |
| + GuardGeneration int64 `json:"guard_generation"` |
| + InputSHA256 string `json:"input_sha256"` |
| + DatabasePath string `json:"database_path"` |
| + DatabaseDevice uint64 `json:"database_device"` |
| + DatabaseInode uint64 `json:"database_inode"` |
| + ExecutionDeadline string `json:"execution_deadline"` |
| + ExecutionKind string `json:"execution_kind,omitempty"` |
| + OperationID string `json:"operation_id,omitempty"` |
| + ExpectedStateSHA256 string `json:"expected_state_sha256,omitempty"` |
| + ClosedCheckpoint *ClosedCheckpoint `json:"closed_checkpoint,omitempty"` |
| +} |
| + |
| +type ClosedCheckpoint struct { |
| + Version int `json:"version"` |
| + PreparationID string `json:"preparation_id"` |
| + CompletedAt string `json:"completed_at"` |
| + MainLength int64 `json:"main_length"` |
| + MainSHA256 string `json:"main_sha256"` |
| + EvidenceSHA256 string `json:"evidence_sha256"` |
| +} |
| + |
| +// ReadLaunch requires EOF and consumes at most the bound plus one byte. |
| +// The worker/adapter must separately bound a reader that can block. |
| +func ReadLaunch(r io.Reader) (LaunchContext, error) { |
| + if r == nil { |
| + return LaunchContext{}, ErrLaunch |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(r, MaxLaunchBytes+1)) |
| + if err != nil { |
| + return LaunchContext{}, ErrLaunch |
| + } |
| + return DecodeLaunch(raw) |
| +} |
| + |
| +func DecodeLaunch(raw []byte) (LaunchContext, error) { |
| + fail := func() (LaunchContext, error) { return LaunchContext{}, ErrLaunch } |
| + if len(raw) == 0 || len(raw) > MaxLaunchBytes || !utf8.Valid(raw) || !validSurrogates(raw) { |
| + return fail() |
| + } |
| + fields, err := launchObject(raw) |
| + if err != nil { |
| + return fail() |
| + } |
| + var version int |
| + if json.Unmarshal(fields["version"], &version) != nil { |
| + return fail() |
| + } |
| + names := strings.Fields("version account app host request_id attempt_id request_digest record_identity artifact_digest environment_envelope_digest guard_generation input_sha256 database_path database_device database_inode execution_deadline") |
| + switch version { |
| + case 1: |
| + case 2: |
| + names = append(names, strings.Fields("execution_kind operation_id expected_state_sha256 closed_checkpoint")...) |
| + cp, err := launchObject(fields["closed_checkpoint"]) |
| + if err != nil || !exactLaunchFields(cp, strings.Fields("version preparation_id completed_at main_length main_sha256 evidence_sha256")) { |
| + return fail() |
| + } |
| + default: |
| + return fail() |
| + } |
| + if !exactLaunchFields(fields, names) { |
| + return fail() |
| + } |
| + var c LaunchContext |
| + if json.Unmarshal(raw, &c) != nil || !c.valid() { |
| + return fail() |
| + } |
| + return c, nil |
| +} |
| + |
| +func EncodeLaunch(c LaunchContext) ([]byte, error) { |
| + if !c.valid() { |
| + return nil, ErrLaunch |
| + } |
| + raw, err := json.Marshal(c) |
| + if err != nil || len(raw) > MaxLaunchBytes { |
| + return nil, ErrLaunch |
| + } |
| + return raw, nil |
| +} |
| + |
| +func launchObject(raw []byte) (map[string]json.RawMessage, error) { |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + tok, err := dec.Token() |
| + if err != nil || tok != json.Delim('{') { |
| + return nil, ErrLaunch |
| + } |
| + out := map[string]json.RawMessage{} |
| + for dec.More() { |
| + tok, err = dec.Token() |
| + if err != nil { |
| + return nil, ErrLaunch |
| + } |
| + key, ok := tok.(string) |
| + if !ok { |
| + return nil, ErrLaunch |
| + } |
| + if _, exists := out[key]; exists { |
| + return nil, ErrLaunch |
| + } |
| + var value json.RawMessage |
| + if dec.Decode(&value) != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { |
| + return nil, ErrLaunch |
| + } |
| + out[key] = value |
| + } |
| + tok, err = dec.Token() |
| + if err != nil || tok != json.Delim('}') { |
| + return nil, ErrLaunch |
| + } |
| + if _, err = dec.Token(); err != io.EOF { |
| + return nil, ErrLaunch |
| + } |
| + return out, nil |
| +} |
| +func exactLaunchFields(fields map[string]json.RawMessage, names []string) bool { |
| + if len(fields) != len(names) { |
| + return false |
| + } |
| + for _, name := range names { |
| + if _, ok := fields[name]; !ok { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func canonicalStamp(s string) (time.Time, bool) { |
| + t, err := time.Parse(time.RFC3339Nano, s) |
| + return t, err == nil && len(s) <= 30 && t.UTC().Format(time.RFC3339Nano) == s |
| +} |
| +func (c LaunchContext) valid() bool { |
| + if !controlname.ValidAccount(c.Account) || !controlname.ValidApp(c.App) || !controlname.ValidHostShape(c.Host) { |
| + return false |
| + } |
| + for _, id := range []string{c.RequestID, c.AttemptID} { |
| + if !maintenanceguard.ValidID(id) { |
| + return false |
| + } |
| + } |
| + for _, digest := range []string{c.RequestDigest, c.RecordIdentity, c.ArtifactDigest, c.EnvironmentEnvelopeDigest, c.InputSHA256} { |
| + if !maintenanceguard.ValidDigest(digest) { |
| + return false |
| + } |
| + } |
| + if c.GuardGeneration <= 0 || c.DatabaseDevice == 0 || c.DatabaseInode == 0 { |
| + return false |
| + } |
| + if len(c.DatabasePath) == 0 || len(c.DatabasePath) > 1024 || c.DatabasePath[0] != '/' || strings.ContainsRune(c.DatabasePath, 0) || !utf8.ValidString(c.DatabasePath) { |
| + return false |
| + } |
| + deadline, ok := canonicalStamp(c.ExecutionDeadline) |
| + if !ok { |
| + return false |
| + } |
| + switch c.Version { |
| + case 1: |
| + return c.ExecutionKind == "" && c.OperationID == "" && c.ExpectedStateSHA256 == "" && c.ClosedCheckpoint == nil |
| + case 2: |
| + if c.ExecutionKind != "compatibility-check" || !maintenanceguard.ValidID(c.OperationID) || !maintenanceguard.ValidDigest(c.ExpectedStateSHA256) || c.ClosedCheckpoint == nil { |
| + return false |
| + } |
| + p := c.ClosedCheckpoint |
| + completed, ok := canonicalStamp(p.CompletedAt) |
| + if !ok || completed.After(deadline) || p.Version != 1 || !maintenanceguard.ValidID(p.PreparationID) || p.MainLength <= 0 || p.MainLength > 268435456 || !maintenanceguard.ValidDigest(p.MainSHA256) || !maintenanceguard.ValidDigest(p.EvidenceSHA256) { |
| + return false |
| + } |
| + return p.EvidenceSHA256 == c.checkpointDigest() |
| + } |
| + return false |
| +} |
| + |
| +// CheckpointDigest correlates private bindings; it does not certify that a |
| +// checkpoint, writer exclusion or read-only namespace actually exists. |
| +func (c LaunchContext) CheckpointDigest() (string, error) { |
| + if c.Version != 2 || c.ClosedCheckpoint == nil { |
| + return "", ErrLaunch |
| + } |
| + return c.checkpointDigest(), nil |
| +} |
| +func (c LaunchContext) checkpointBytes() []byte { |
| + p := c.ClosedCheckpoint |
| + raw, _ := json.Marshal([]any{"carlos/closed-checkpoint/v1", c.Account, c.App, c.Host, c.OperationID, c.RequestID, c.AttemptID, c.RequestDigest, c.RecordIdentity, c.ArtifactDigest, c.EnvironmentEnvelopeDigest, c.GuardGeneration, c.InputSHA256, c.ExpectedStateSHA256, c.DatabasePath, c.DatabaseDevice, c.DatabaseInode, c.ExecutionDeadline, p.PreparationID, p.CompletedAt, p.MainLength, p.MainSHA256}) |
| + return raw |
| +} |
| +func (c LaunchContext) checkpointDigest() string { |
| + sum := sha256.Sum256(c.checkpointBytes()) |
| + return hex.EncodeToString(sum[:]) |
| +} |
diff --git a/internal/maintenance/launch_test.go b/internal/maintenance/launch_test.go| new file mode 100644 |
| index 000000000..51faa7c22 |
| --- /dev/null |
| +++ b/internal/maintenance/launch_test.go |
| @@ -0,0 +1,274 @@ |
| +package maintenance |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func launchFixture(t *testing.T) ([]byte, LaunchContext) { |
| + t.Helper() |
| + raw, err := os.ReadFile("testdata/launch-v2.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := DecodeLaunch(raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw, c |
| +} |
| +func TestLaunchExactIntegersAndIndependentCheckpointVector(t *testing.T) { |
| + _, c := launchFixture(t) |
| + if c.DatabaseDevice != ^uint64(0) || c.DatabaseInode != 9007199254740993 || c.GuardGeneration != 9223372036854775807 { |
| + t.Fatal("integer precision lost") |
| + } |
| + raw, err := EncodeLaunch(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := ReadLaunch(bytes.NewReader(raw)) |
| + if err != nil || again.DatabaseDevice != c.DatabaseDevice || again.DatabaseInode != c.DatabaseInode { |
| + t.Fatal("round trip lost identity", err) |
| + } |
| + binding, err := os.ReadFile("testdata/launch-v2-binding.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Contains(binding, []byte(`18446744073709551615`)) || !bytes.Contains(binding, []byte(`\u003chome\u003e\u0026`)) { |
| + t.Fatal("independent vector lacks precision/escaping cases") |
| + } |
| + if !bytes.Equal(binding, c.checkpointBytes()) { |
| + t.Fatal("Go binding differs from independent Python vector") |
| + } |
| + digest := sha256.Sum256(binding) |
| + if hex.EncodeToString(digest[:]) != c.ClosedCheckpoint.EvidenceSHA256 { |
| + t.Fatal("independent vector digest mismatch") |
| + } |
| + c.Version = 1 |
| + c.ExecutionKind = "" |
| + c.OperationID = "" |
| + c.ExpectedStateSHA256 = "" |
| + c.ClosedCheckpoint = nil |
| + raw, err = EncodeLaunch(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := DecodeLaunch(raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.ExecutionKind = "compatibility-check" |
| + if _, err := EncodeLaunch(c); err == nil { |
| + t.Fatal("v1 accepted checker fields") |
| + } |
| +} |
| +func TestLaunchRefusesEachMissingOrAmbiguousField(t *testing.T) { |
| + raw, _ := launchFixture(t) |
| + var fields map[string]json.RawMessage |
| + if err := json.Unmarshal(raw, &fields); err != nil { |
| + t.Fatal(err) |
| + } |
| + for key := range fields { |
| + t.Run(key, func(t *testing.T) { |
| + var f map[string]json.RawMessage |
| + json.Unmarshal(raw, &f) |
| + delete(f, key) |
| + bad, _ := json.Marshal(f) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("missing field accepted") |
| + } |
| + f[strings.ToUpper(key)] = fields[key] |
| + bad, _ = json.Marshal(f) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("case alias accepted") |
| + } |
| + f[key] = json.RawMessage("null") |
| + delete(f, strings.ToUpper(key)) |
| + bad, _ = json.Marshal(f) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("null accepted") |
| + } |
| + }) |
| + } |
| + for _, bad := range [][]byte{append(append([]byte{}, raw...), []byte(` {}`)...), append([]byte(`{"version":2,`), bytes.TrimSpace(raw)[1:]...), bytes.Replace(raw, []byte(`"database_inode": 9007199254740993`), []byte(`"database_inode": 18446744073709551616`), 1), bytes.Replace(raw, []byte(`"guard_generation": 9223372036854775807`), []byte(`"guard_generation": 1e3`), 1), bytes.Replace(raw, []byte(`"database_inode": 9007199254740993`), []byte(`"database_inode": 1.0`), 1), bytes.Replace(raw, []byte(`"version": 2`), []byte(`"version": 2.0`), 1), bytes.Replace(raw, []byte(`"main_length": 268435456`), []byte(`"main_length": 268435457`), 1)} { |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatalf("malformed context accepted: %s", bad) |
| + } |
| + } |
| +} |
| +func TestLaunchCheckpointCannotBeTransplanted(t *testing.T) { |
| + _, c := launchFixture(t) |
| + raw, _ := EncodeLaunch(c) |
| + var fields map[string]json.RawMessage |
| + json.Unmarshal(raw, &fields) |
| + for key := range fields { |
| + if key == "closed_checkpoint" || key == "version" || key == "execution_kind" { |
| + continue |
| + } |
| + t.Run(key, func(t *testing.T) { |
| + var f map[string]json.RawMessage |
| + json.Unmarshal(raw, &f) |
| + var value any |
| + switch key { |
| + case "account": |
| + value = "abc" |
| + case "app": |
| + value = "docs" |
| + case "host": |
| + value = "other.bds.oncarlos.com" |
| + case "request_id", "attempt_id", "operation_id": |
| + value = strings.Repeat("A", 43) |
| + case "database_path": |
| + value = "/other/home.db" |
| + case "execution_deadline": |
| + value = "2026-09-11T21:00:00Z" |
| + case "guard_generation", "database_device", "database_inode": |
| + value = 2 |
| + default: |
| + value = strings.Repeat("a", 64) |
| + } |
| + f[key], _ = json.Marshal(value) |
| + bad, _ := json.Marshal(f) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("changed valid binding accepted with old evidence") |
| + } |
| + var changed LaunchContext |
| + json.Unmarshal(bad, &changed) |
| + changed.ClosedCheckpoint.EvidenceSHA256 = changed.checkpointDigest() |
| + if _, err := EncodeLaunch(changed); err != nil { |
| + t.Fatal("mutation was not valid independent of its evidence", err) |
| + } |
| + }) |
| + } |
| + for _, mutate := range []func(*ClosedCheckpoint){func(p *ClosedCheckpoint) { p.PreparationID = strings.Repeat("A", 43) }, func(p *ClosedCheckpoint) { p.MainLength-- }, func(p *ClosedCheckpoint) { p.MainSHA256 = strings.Repeat("a", 64) }, func(p *ClosedCheckpoint) { p.CompletedAt = "2026-09-11T19:59:58Z" }} { |
| + _, v := launchFixture(t) |
| + mutate(v.ClosedCheckpoint) |
| + b, _ := json.Marshal(v) |
| + if _, err := DecodeLaunch(b); err == nil { |
| + t.Fatal("changed preparation accepted") |
| + } |
| + } |
| +} |
| +func TestLaunchSizeUTF8AndSurrogates(t *testing.T) { |
| + _, c := launchFixture(t) |
| + c.Version = 1 |
| + c.ExecutionKind = "" |
| + c.OperationID = "" |
| + c.ExpectedStateSHA256 = "" |
| + c.ClosedCheckpoint = nil |
| + raw, err := EncodeLaunch(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + exact := append(bytes.Repeat([]byte(" "), MaxLaunchBytes-len(raw)), raw...) |
| + if _, err := DecodeLaunch(exact); err != nil { |
| + t.Fatal("exact bound refused", err) |
| + } |
| + r := bytes.NewReader(append(exact, []byte("xx")...)) |
| + if _, err := ReadLaunch(r); err == nil || r.Len() != 1 { |
| + t.Fatal("overflow bound", err, r.Len()) |
| + } |
| + for _, path := range []string{`"/bad\ud800"`, `"/bad\udc00"`, `"/bad\ud800\u0001"`, `"/bad` + string([]byte{0xff}) + `"`} { |
| + oldPath, _ := json.Marshal(c.DatabasePath) |
| + bad := bytes.Replace(raw, oldPath, []byte(path), 1) |
| + if bytes.Equal(bad, raw) { |
| + t.Fatal("mutation missed path") |
| + } |
| + |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("invalid string accepted") |
| + } |
| + } |
| + c.DatabasePath = "/valid/😀/�/quoted\".db" |
| + raw, err = EncodeLaunch(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw = bytes.ReplaceAll(raw, []byte("😀"), []byte(`\ud83d\ude00`)) |
| + if _, err := DecodeLaunch(raw); err != nil { |
| + t.Fatal("valid surrogate pair refused", err) |
| + } |
| + if _, err := ReadLaunch(io.MultiReader(bytes.NewReader(raw), errReader{})); err == nil { |
| + t.Fatal("read error accepted") |
| + } |
| +} |
| + |
| +type errReader struct{} |
| + |
| +func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } |
| + |
| +func TestNestedLaunchFieldsAreClosed(t *testing.T) { |
| + raw, _ := launchFixture(t) |
| + var top, fields map[string]json.RawMessage |
| + json.Unmarshal(raw, &top) |
| + original := top["closed_checkpoint"] |
| + json.Unmarshal(original, &fields) |
| + refuse := func(b json.RawMessage) { |
| + t.Helper() |
| + top["closed_checkpoint"] = b |
| + bad, _ := json.Marshal(top) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("ambiguous checkpoint accepted") |
| + } |
| + } |
| + for key, value := range fields { |
| + t.Run(key, func(t *testing.T) { |
| + var f map[string]json.RawMessage |
| + json.Unmarshal(original, &f) |
| + delete(f, key) |
| + bad, _ := json.Marshal(f) |
| + refuse(bad) |
| + f[strings.ToUpper(key)] = value |
| + bad, _ = json.Marshal(f) |
| + refuse(bad) |
| + delete(f, strings.ToUpper(key)) |
| + f[key] = json.RawMessage("null") |
| + bad, _ = json.Marshal(f) |
| + refuse(bad) |
| + prefix, _ := json.Marshal(key) |
| + dup := append(append(append([]byte{'{'}, prefix...), ':'), value...) |
| + dup = append(dup, ',') |
| + dup = append(dup, bytes.TrimSpace(original)[1:]...) |
| + refuse(dup) |
| + }) |
| + } |
| + var f map[string]json.RawMessage |
| + json.Unmarshal(original, &f) |
| + f["extra"] = json.RawMessage("1") |
| + bad, _ := json.Marshal(f) |
| + refuse(bad) |
| +} |
| +func TestLaunchNumericAndTimestampLexicalForms(t *testing.T) { |
| + raw, c := launchFixture(t) |
| + for _, key := range []string{"version", "guard_generation", "database_device", "database_inode"} { |
| + for _, value := range []string{"0", "-0", "-1", "1.0", "1e0", "18446744073709551616"} { |
| + var f map[string]json.RawMessage |
| + json.Unmarshal(raw, &f) |
| + f[key] = json.RawMessage(value) |
| + bad, _ := json.Marshal(f) |
| + if _, err := DecodeLaunch(bad); err == nil { |
| + t.Fatal("noncanonical integer accepted", key, value) |
| + } |
| + } |
| + } |
| + for _, stamp := range []string{"2026-09-11T20:00:00+00:00", "2026-09-11T20:00:00.120Z", "2026-09-11t20:00:00Z", "2026-09-11T20:00:00z"} { |
| + v := c |
| + v.ExecutionDeadline = stamp |
| + v.ClosedCheckpoint = &ClosedCheckpoint{} |
| + *v.ClosedCheckpoint = *c.ClosedCheckpoint |
| + v.ClosedCheckpoint.EvidenceSHA256 = v.checkpointDigest() |
| + if _, err := EncodeLaunch(v); err == nil { |
| + t.Fatal("noncanonical timestamp accepted", stamp) |
| + } |
| + } |
| + c.ClosedCheckpoint.CompletedAt = c.ExecutionDeadline |
| + c.ClosedCheckpoint.EvidenceSHA256 = c.checkpointDigest() |
| + if _, err := EncodeLaunch(c); err != nil { |
| + t.Fatal("equal completion/deadline refused", err) |
| + } |
| +} |
diff --git a/internal/maintenance/launch_utf8.go b/internal/maintenance/launch_utf8.go| new file mode 100644 |
| index 000000000..ba1cdb955 |
| --- /dev/null |
| +++ b/internal/maintenance/launch_utf8.go |
| @@ -0,0 +1,48 @@ |
| +package maintenance |
| + |
| +import "strconv" |
| + |
| +// encoding/json replaces unpaired UTF-16 escapes with U+FFFD. Refuse that |
| +// lossy conversion before decoding identities and paths. |
| +func validSurrogates(raw []byte) bool { |
| + quoted := false |
| + for i := 0; i < len(raw); i++ { |
| + if raw[i] == '"' { |
| + quoted = !quoted |
| + continue |
| + } |
| + if !quoted || raw[i] != '\\' { |
| + continue |
| + } |
| + i++ |
| + if i >= len(raw) { |
| + return false |
| + } |
| + if raw[i] != 'u' { |
| + continue |
| + } |
| + if i+4 >= len(raw) { |
| + return false |
| + } |
| + n, err := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16) |
| + if err != nil { |
| + return false |
| + } |
| + i += 4 |
| + if n >= 0xdc00 && n <= 0xdfff { |
| + return false |
| + } |
| + if n < 0xd800 || n > 0xdbff { |
| + continue |
| + } |
| + if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' { |
| + return false |
| + } |
| + low, err := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16) |
| + if err != nil || low < 0xdc00 || low > 0xdfff { |
| + return false |
| + } |
| + i += 6 |
| + } |
| + return true |
| +} |
diff --git a/internal/maintenance/testdata/generate-launch-vector.py b/internal/maintenance/testdata/generate-launch-vector.py| new file mode 100644 |
| index 000000000..81b4481d3 |
| --- /dev/null |
| +++ b/internal/maintenance/testdata/generate-launch-vector.py |
| @@ -0,0 +1,44 @@ |
| +#!/usr/bin/env python3 |
| +"""Generate the launch binding with Python integers, independent of Go code.""" |
| +import base64 |
| +import hashlib |
| +import json |
| +from pathlib import Path |
| + |
| +root = Path(__file__).parent |
| +identity = lambda n: base64.urlsafe_b64encode(bytes([n]) * 32).decode().rstrip('=') |
| +c = { |
| + 'version': 2, 'account': 'bds', 'app': 'home', |
| + 'host': 'overnight-home.bds.oncarlos.com', |
| + 'request_id': identity(1), 'attempt_id': identity(2), |
| + 'request_digest': '1' * 64, 'record_identity': '2' * 64, |
| + 'artifact_digest': '3' * 64, 'environment_envelope_digest': '4' * 64, |
| + 'guard_generation': 9223372036854775807, 'input_sha256': '5' * 64, |
| + 'database_path': '/data/<home>&/☃.db', |
| + 'database_device': 18446744073709551615, 'database_inode': 9007199254740993, |
| + 'execution_deadline': '2026-09-11T20:00:00.123456789Z', |
| + 'execution_kind': 'compatibility-check', 'operation_id': identity(3), |
| + 'expected_state_sha256': '6' * 64, |
| + 'closed_checkpoint': { |
| + 'version': 1, 'preparation_id': identity(4), |
| + 'completed_at': '2026-09-11T19:59:59.123456789Z', |
| + 'main_length': 268435456, 'main_sha256': '7' * 64, |
| + 'evidence_sha256': '', |
| + }, |
| +} |
| +p = c['closed_checkpoint'] |
| +values = ['carlos/closed-checkpoint/v1'] + [c[k] for k in [ |
| + 'account', 'app', 'host', 'operation_id', 'request_id', 'attempt_id', |
| + 'request_digest', 'record_identity', 'artifact_digest', |
| + 'environment_envelope_digest', 'guard_generation', 'input_sha256', |
| + 'expected_state_sha256', 'database_path', 'database_device', |
| + 'database_inode', 'execution_deadline', |
| +]] + [p[k] for k in ['preparation_id', 'completed_at', 'main_length', 'main_sha256']] |
| +binding = json.dumps(values, ensure_ascii=False, separators=(',', ':')) |
| +for char, escaped in [('<', '\\u003c'), ('>', '\\u003e'), ('&', '\\u0026'), |
| + ('\u2028', '\\u2028'), ('\u2029', '\\u2029')]: |
| + binding = binding.replace(char, escaped) |
| +binding = binding.encode() |
| +p['evidence_sha256'] = hashlib.sha256(binding).hexdigest() |
| +(root / 'launch-v2.json').write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n') |
| +(root / 'launch-v2-binding.json').write_bytes(binding) |
diff --git a/internal/maintenance/testdata/launch-v2-binding.json b/internal/maintenance/testdata/launch-v2-binding.json| new file mode 100644 |
| index 000000000..5b09c62b4 |
| --- /dev/null |
| +++ b/internal/maintenance/testdata/launch-v2-binding.json |
| @@ -0,0 +1 @@ |
| +["carlos/closed-checkpoint/v1","bds","home","overnight-home.bds.oncarlos.com","AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM","AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","1111111111111111111111111111111111111111111111111111111111111111","2222222222222222222222222222222222222222222222222222222222222222","3333333333333333333333333333333333333333333333333333333333333333","4444444444444444444444444444444444444444444444444444444444444444",9223372036854775807,"5555555555555555555555555555555555555555555555555555555555555555","6666666666666666666666666666666666666666666666666666666666666666","/data/\u003chome\u003e\u0026/☃.db",18446744073709551615,9007199254740993,"2026-09-11T20:00:00.123456789Z","BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ","2026-09-11T19:59:59.123456789Z",268435456,"7777777777777777777777777777777777777777777777777777777777777777"] |
| \ No newline at end of file |
diff --git a/internal/maintenance/testdata/launch-v2.json b/internal/maintenance/testdata/launch-v2.json| new file mode 100644 |
| index 000000000..39fd33ff7 |
| --- /dev/null |
| +++ b/internal/maintenance/testdata/launch-v2.json |
| @@ -0,0 +1,29 @@ |
| +{ |
| + "version": 2, |
| + "account": "bds", |
| + "app": "home", |
| + "host": "overnight-home.bds.oncarlos.com", |
| + "request_id": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", |
| + "attempt_id": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI", |
| + "request_digest": "1111111111111111111111111111111111111111111111111111111111111111", |
| + "record_identity": "2222222222222222222222222222222222222222222222222222222222222222", |
| + "artifact_digest": "3333333333333333333333333333333333333333333333333333333333333333", |
| + "environment_envelope_digest": "4444444444444444444444444444444444444444444444444444444444444444", |
| + "guard_generation": 9223372036854775807, |
| + "input_sha256": "5555555555555555555555555555555555555555555555555555555555555555", |
| + "database_path": "/data/<home>&/☃.db", |
| + "database_device": 18446744073709551615, |
| + "database_inode": 9007199254740993, |
| + "execution_deadline": "2026-09-11T20:00:00.123456789Z", |
| + "execution_kind": "compatibility-check", |
| + "operation_id": "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM", |
| + "expected_state_sha256": "6666666666666666666666666666666666666666666666666666666666666666", |
| + "closed_checkpoint": { |
| + "version": 1, |
| + "preparation_id": "BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ", |
| + "completed_at": "2026-09-11T19:59:59.123456789Z", |
| + "main_length": 268435456, |
| + "main_sha256": "7777777777777777777777777777777777777777777777777777777777777777", |
| + "evidence_sha256": "3dd1eda296f15c602461c856bd344fb4dacc07780c6bfd33f9f756c6a6c34dda" |
| + } |
| +} |
diff --git a/internal/maintenanceexec/errors.go b/internal/maintenanceexec/errors.go| new file mode 100644 |
| index 000000000..818cdcf84 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/errors.go |
| @@ -0,0 +1,6 @@ |
| +package maintenanceexec |
| + |
| +import "errors" |
| + |
| +var ErrRefused = errors.New("maintenance execution: refused") |
| +var ErrUncertain = errors.New("maintenance execution: process cleanup uncertain") |
diff --git a/internal/maintenanceexec/pipes_linux.go b/internal/maintenanceexec/pipes_linux.go| new file mode 100644 |
| index 000000000..2198881bf |
| --- /dev/null |
| +++ b/internal/maintenanceexec/pipes_linux.go |
| @@ -0,0 +1,33 @@ |
| +//go:build linux |
| + |
| +package maintenanceexec |
| + |
| +import "os" |
| + |
| +type processPipes struct{ inR, inW, outR, outW, contextR, contextW *os.File } |
| + |
| +func newPipes() (p *processPipes, err error) { |
| + p = &processPipes{} |
| + defer func() { |
| + if err != nil { |
| + p.close() |
| + } |
| + }() |
| + if p.inR, p.inW, err = os.Pipe(); err != nil { |
| + return p, err |
| + } |
| + if p.outR, p.outW, err = os.Pipe(); err != nil { |
| + return p, err |
| + } |
| + if p.contextR, p.contextW, err = os.Pipe(); err != nil { |
| + return p, err |
| + } |
| + return p, nil |
| +} |
| +func (p *processPipes) close() { |
| + for _, f := range []*os.File{p.inR, p.inW, p.outR, p.outW, p.contextR, p.contextW} { |
| + if f != nil { |
| + f.Close() |
| + } |
| + } |
| +} |
diff --git a/internal/maintenanceexec/run_linux.go b/internal/maintenanceexec/run_linux.go| new file mode 100644 |
| index 000000000..252662fe6 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/run_linux.go |
| @@ -0,0 +1,287 @@ |
| +//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 |
| +} |
diff --git a/internal/maintenanceexec/run_linux_test.go b/internal/maintenanceexec/run_linux_test.go| new file mode 100644 |
| index 000000000..9b65cf118 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/run_linux_test.go |
| @@ -0,0 +1,362 @@ |
| +//go:build linux |
| + |
| +package maintenanceexec |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "reflect" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/maintenance" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// This fixture exercises actual exec and pipe behavior. Its process-group |
| +// cleanup is a test substitute, not native cgroup containment evidence. |
| +type fixtureGroup struct { |
| + cmd *exec.Cmd |
| + starts, kills int |
| + startErr, killErr error |
| + before func() |
| +} |
| + |
| +func (g *fixtureGroup) Start(c *exec.Cmd) error { |
| + g.starts++ |
| + g.cmd = c |
| + if g.before != nil { |
| + g.before() |
| + } |
| + if g.startErr != nil { |
| + return g.startErr |
| + } |
| + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| + return c.Start() |
| +} |
| +func (g *fixtureGroup) Populated() (bool, error) { |
| + if g.cmd == nil || g.cmd.Process == nil { |
| + return false, nil |
| + } |
| + entries, err := os.ReadDir("/proc") |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, entry := range entries { |
| + if _, err := strconv.Atoi(entry.Name()); err != nil { |
| + continue |
| + } |
| + raw, err := os.ReadFile("/proc/" + entry.Name() + "/stat") |
| + if err != nil { |
| + continue |
| + } |
| + fields := strings.Fields(string(raw)[strings.LastIndexByte(string(raw), ')')+1:]) |
| + if len(fields) >= 3 && fields[2] == strconv.Itoa(g.cmd.Process.Pid) && fields[0] != "Z" { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| +func (g *fixtureGroup) KillAndEmpty(ctx context.Context) error { |
| + if ctx.Err() != nil { |
| + return ctx.Err() |
| + } |
| + g.kills++ |
| + if g.cmd != nil && g.cmd.Process != nil { |
| + unix.Kill(-g.cmd.Process.Pid, unix.SIGKILL) |
| + } |
| + return g.killErr |
| +} |
| + |
| +type fixtureLease struct { |
| + mu sync.Mutex |
| + until time.Time |
| + lost chan struct{} |
| + err error |
| +} |
| + |
| +func (l *fixtureLease) SafeUntil() (time.Time, error) { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.until, l.err |
| +} |
| +func (l *fixtureLease) Lost() <-chan struct{} { return l.lost } |
| +func (l *fixtureLease) lose() { l.mu.Lock(); defer l.mu.Unlock(); l.err = ErrRefused; close(l.lost) } |
| + |
| +func TestChildFixture(t *testing.T) { |
| + mode := os.Getenv("MAINTENANCE_EXEC_FIXTURE") |
| + if mode == "" { |
| + return |
| + } |
| + if len(os.Environ()) != 2 || os.Getenv("GODEBUG") != "containermaxprocs=0,updatemaxprocs=0" { |
| + os.Exit(15) |
| + } |
| + if cwd, err := os.Getwd(); err != nil || cwd != "/" { |
| + os.Exit(16) |
| + } |
| + stderr, err := os.Stderr.Stat() |
| + null, nullErr := os.Stat(os.DevNull) |
| + if err != nil || nullErr != nil || !os.SameFile(stderr, null) { |
| + os.Exit(17) |
| + } |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + os.Exit(18) |
| + } |
| + for _, entry := range entries { |
| + fd, err := strconv.Atoi(entry.Name()) |
| + if err != nil || fd <= 4 { |
| + continue |
| + } |
| + target, err := os.Readlink("/proc/self/fd/" + entry.Name()) |
| + if errors.Is(err, os.ErrNotExist) || strings.HasPrefix(target, "anon_inode:") { |
| + continue |
| + } |
| + os.Exit(19) |
| + } |
| + if mode == "blocked" { |
| + time.Sleep(time.Minute) |
| + return |
| + } |
| + if mode == "overflow" { |
| + os.Stdout.Write([]byte(strings.Repeat("x", maintenance.MaxResultBytes+1))) |
| + time.Sleep(time.Minute) |
| + return |
| + } |
| + if mode == "nonzero" { |
| + os.Exit(7) |
| + } |
| + if mode == "descendant" { |
| + child := exec.Command("/proc/self/exe", "-test.run=^TestChildFixture$") |
| + child.Env = []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=blocked"} |
| + child.Dir = "/" |
| + child.Stdout = os.Stdout |
| + if child.Start() != nil { |
| + os.Exit(14) |
| + } |
| + } |
| + c, err := maintenance.ReadLaunch(os.NewFile(3, "context")) |
| + if err != nil { |
| + os.Exit(8) |
| + } |
| + pin := os.NewFile(4, "artifact") |
| + info, err := pin.Stat() |
| + if err != nil { |
| + os.Exit(9) |
| + } |
| + self, err := os.Stat("/proc/self/exe") |
| + if err != nil || !os.SameFile(info, self) { |
| + os.Exit(10) |
| + } |
| + flags, err := unix.FcntlInt(pin.Fd(), unix.F_GETFL, 0) |
| + if err != nil || flags&unix.O_ACCMODE != unix.O_RDONLY { |
| + os.Exit(11) |
| + } |
| + raw, err := io.ReadAll(os.Stdin) |
| + if err != nil { |
| + os.Exit(12) |
| + } |
| + sum := sha256.Sum256(raw) |
| + if hex.EncodeToString(sum[:]) != c.InputSHA256 { |
| + os.Exit(13) |
| + } |
| + json.NewEncoder(os.Stdout).Encode(map[string]any{"input_bytes": len(raw), "bound_image": true}) |
| + os.Exit(0) |
| +} |
| +func executionFixture(t *testing.T) (*os.File, maintenance.LaunchContext, maintenance.Input, *fixtureGroup, *fixtureLease) { |
| + t.Helper() |
| + t.Setenv("MAINTENANCE_PARENT_ONLY", "must-not-reach-child") |
| + src, err := os.Open("/proc/self/exe") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer src.Close() |
| + path := filepath.Join(t.TempDir(), "image") |
| + dst, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hash := sha256.New() |
| + if _, err := io.Copy(io.MultiWriter(dst, hash), src); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := dst.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + pin, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { pin.Close() }) |
| + input := maintenance.Input{Arguments: []string{"-test.run=^TestChildFixture$"}, Stdin: []byte(`{"test":"` + strings.Repeat("a", 60000) + `"}`)} |
| + sum := sha256.Sum256(input.Stdin) |
| + id := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) |
| + digest := strings.Repeat("a", 64) |
| + c := maintenance.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.bds.oncarlos.com", RequestID: id, AttemptID: id, RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: hex.EncodeToString(hash.Sum(nil)), EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(sum[:]), DatabasePath: "/synthetic/home.db", DatabaseDevice: 1, DatabaseInode: 2, ExecutionDeadline: time.Now().Add(8 * time.Second).UTC().Format(time.RFC3339Nano)} |
| + return pin, c, input, &fixtureGroup{}, &fixtureLease{until: time.Now().Add(10 * time.Second), lost: make(chan struct{})} |
| +} |
| +func TestPinnedImageAndExactPrivateInput(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + g.before = func() { |
| + if err := os.Rename(pin.Name(), pin.Name()+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(pin.Name(), []byte("not an executable"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| + if err != nil { |
| + t.Fatal(err, g.cmd.ProcessState) |
| + } |
| + var result struct { |
| + InputBytes int `json:"input_bytes"` |
| + BoundImage bool `json:"bound_image"` |
| + } |
| + if json.Unmarshal(out, &result) != nil || result.InputBytes != len(input.Stdin) || !result.BoundImage { |
| + t.Fatal("wrong child result") |
| + } |
| + if g.starts != 1 || g.kills != 1 { |
| + t.Fatal("wrong containment lifecycle", g.starts, g.kills) |
| + } |
| +} |
| +func TestBlockedIOOverflowFailureAndLeaseLoss(t *testing.T) { |
| + for _, mode := range []string{"blocked", "overflow", "nonzero", "lease-loss"} { |
| + t.Run(mode, func(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) |
| + defer cancel() |
| + fixtureMode := mode |
| + if mode == "lease-loss" { |
| + fixtureMode = "blocked" |
| + g.before = func() { go func() { time.Sleep(50 * time.Millisecond); l.lose() }() } |
| + } |
| + started := time.Now() |
| + out, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=" + fixtureMode}) |
| + if err == nil || len(out) != 0 || g.starts != 1 || g.kills == 0 { |
| + t.Fatal("failure became success", err, g.starts, g.kills) |
| + } |
| + if time.Since(started) > 3*time.Second { |
| + t.Fatal("supervisor blocked") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestDescendantHoldingOutputPipe(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Second) |
| + defer cancel() |
| + started := time.Now() |
| + out, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=descendant"}) |
| + if err != ErrRefused || len(out) != 0 || g.kills != 1 { |
| + t.Fatal("surviving descendant was accepted", err, g.kills) |
| + } |
| + if time.Since(started) > 3*time.Second { |
| + t.Fatal("waited for descendant instead of terminating it") |
| + } |
| +} |
| + |
| +func TestExpiredLeaseStillTerminatesProcess(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + l.until = time.Now().Add(2200 * time.Millisecond) |
| + g.before = func() { time.Sleep(time.Until(l.until) + 10*time.Millisecond) } |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=blocked"}) |
| + if err != ErrUncertain || len(out) != 0 || g.kills == 0 { |
| + t.Fatal("expired lease skipped termination", err, g.kills) |
| + } |
| +} |
| + |
| +func descriptorSnapshot(t *testing.T) map[string]string { |
| + t.Helper() |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for _, entry := range entries { |
| + target, err := os.Readlink("/proc/self/fd/" + entry.Name()) |
| + if errors.Is(err, os.ErrNotExist) || strings.HasPrefix(target, "anon_inode:") { |
| + continue |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out[entry.Name()] = target |
| + } |
| + return out |
| +} |
| + |
| +func TestParentDescriptorsClose(t *testing.T) { |
| + for _, mode := range []string{"success", "blocked", "overflow", "nonzero", "descendant"} { |
| + t.Run(mode, func(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + before := descriptorSnapshot(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + _, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=" + mode}) |
| + if (err == nil) != (mode == "success") { |
| + t.Fatal("wrong result", err) |
| + } |
| + if !reflect.DeepEqual(before, descriptorSnapshot(t)) { |
| + t.Fatal("parent descriptor set changed after execution") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestInputRemainsBoundAfterPreflight(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + g.before = func() { input.Stdin[10] = 'b' } |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| + if err != nil || len(out) == 0 { |
| + t.Fatal("child received input changed after validation", err) |
| + } |
| +} |
| +func TestPreflightRefusesWithoutSpawn(t *testing.T) { |
| + for _, mode := range []string{"input", "artifact", "deadline", "checker", "lease"} { |
| + t.Run(mode, func(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + switch mode { |
| + case "input": |
| + input.Stdin = []byte(`{}`) |
| + case "artifact": |
| + c.ArtifactDigest = strings.Repeat("b", 64) |
| + case "deadline": |
| + c.ExecutionDeadline = time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano) |
| + case "checker": |
| + c.Version = 2 |
| + case "lease": |
| + l.lose() |
| + } |
| + if _, err := run(t.Context(), g, l, pin, c, input, nil); err == nil || g.starts != 0 { |
| + t.Fatal("invalid launch spawned") |
| + } |
| + }) |
| + } |
| +} |
| +func TestFailedStartAndUncertainCleanup(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + g.startErr = errors.New("synthetic private start detail") |
| + if _, err := run(t.Context(), g, l, pin, c, input, nil); err != ErrRefused || g.starts != 1 || g.kills != 1 { |
| + t.Fatal("start failure lost cleanup", err) |
| + } |
| + pin, c, input, g, l = executionFixture(t) |
| + g.killErr = errors.New("synthetic private cleanup detail") |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| + if err != ErrUncertain || len(out) != 0 { |
| + t.Fatal("uncertain cleanup accepted", err) |
| + } |
| +} |
diff --git a/internal/maintenanceexec/run_other.go b/internal/maintenanceexec/run_other.go| new file mode 100644 |
| index 000000000..0aa531438 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/run_other.go |
| @@ -0,0 +1,16 @@ |
| +//go:build !linux |
| + |
| +package maintenanceexec |
| + |
| +import ( |
| + "context" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/maintenance" |
| +) |
| + |
| +func Run(context.Context, *cgroup.Prepared, *lease.Held, *os.File, maintenance.LaunchContext, maintenance.Input, []string) ([]byte, error) { |
| + return nil, ErrRefused |
| +} |