carlos / platform Public

Share maintenance launch decoding with standalone consumers

Paul Campbell pushed by paul@keymail.dev 4351da00d34f40cbeba9d19f5d0c0892e7763c9e parent 717bdd8
16 files changed, +344 −254
  • Makefile +1 −0
  • go.mod +4 −0
  • internal/controlname/names.go +5 −25
  • internal/maintenance/launch.go +8 −212
  • internal/maintenanceguard/types.go +3 −15
  • launchctx/controlname/names.go +28 −0
  • launchctx/go.mod +3 −0
  • launchctx/launch.go +221 −0
  • {internal/maintenance => launchctx}/launch_test.go +1 −1
  • {internal/maintenance => launchctx}/launch_utf8.go +1 −1
  • launchctx/mode.go +17 −0
  • launchctx/mode_test.go +31 −0
  • {internal/maintenance => launchctx}/testdata/generate-launch-vector.py +0 −0
  • {internal/maintenance => launchctx}/testdata/launch-v2-binding.json +0 −0
  • {internal/maintenance => launchctx}/testdata/launch-v2.json +0 −0
  • launchctx/wireid/id.go +21 −0

CI cancelled — superseded by a newer push — run details

diff --git a/Makefile b/Makefile
index 5a963c1e4..8228319ee 100644
--- a/Makefile
+++ b/Makefile
@@ -12,6 +12,7 @@ GOARCH ?=
# The 30m timeout is above Go's 10m default because the root package's e2e
# tests compile the carlos binary many times over.
test:
+ cd launchctx && go vet ./... && go test ./... -count=1
go vet ./...
@fmt_out=$$(gofmt -l .); if [ -n "$$fmt_out" ]; then echo "gofmt needed:"; echo "$$fmt_out"; exit 1; fi; echo "gofmt -l .: clean"
go test ./... -count=1 -timeout 30m
diff --git a/go.mod b/go.mod
index 698b9d75f..80433695b 100644
--- a/go.mod
+++ b/go.mod
@@ -78,3 +78,7 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+
+require amadan.net/carlos/platform/launchctx v0.0.0
+
+replace amadan.net/carlos/platform/launchctx => ./launchctx
diff --git a/internal/controlname/names.go b/internal/controlname/names.go
index 51c0876fa..ee4341eda 100644
--- a/internal/controlname/names.go
+++ b/internal/controlname/names.go
@@ -1,28 +1,8 @@
-// Package controlname validates identifiers used as control-plane key segments.
package controlname
-import (
- "regexp"
- "strings"
-)
+import names "amadan.net/carlos/platform/launchctx/controlname"
-var (
- account = regexp.MustCompile(`^[a-z]{3,16}$`)
- app = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
- label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
-)
-
-func ValidAccount(s string) bool { return account.MatchString(s) }
-func ValidApp(s string) bool { return app.MatchString(s) }
-func ValidLabel(s string) bool { return label.MatchString(s) }
-func ValidHostShape(host string) bool {
- if host == "" || len(host) > 253 {
- return false
- }
- for _, part := range strings.Split(host, ".") {
- if !ValidLabel(part) {
- return false
- }
- }
- return true
-}
+func ValidAccount(s string) bool { return names.ValidAccount(s) }
+func ValidApp(s string) bool { return names.ValidApp(s) }
+func ValidLabel(s string) bool { return names.ValidLabel(s) }
+func ValidHostShape(s string) bool { return names.ValidHostShape(s) }
diff --git a/internal/maintenance/launch.go b/internal/maintenance/launch.go
index edc48b0d9..3eecc811f 100644
--- a/internal/maintenance/launch.go
+++ b/internal/maintenance/launch.go
@@ -1,221 +1,17 @@
package maintenance
import (
- "bytes"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
+ "amadan.net/carlos/platform/launchctx"
"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
-}
+const MaxLaunchBytes = launchctx.MaxLaunchBytes
-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
-}
+var ErrLaunch = launchctx.ErrLaunch
-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
-}
+type LaunchContext = launchctx.LaunchContext
+type ClosedCheckpoint = launchctx.ClosedCheckpoint
-// 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[:])
-}
+func ReadLaunch(r io.Reader) (LaunchContext, error) { return launchctx.ReadLaunch(r) }
+func DecodeLaunch(raw []byte) (LaunchContext, error) { return launchctx.DecodeLaunch(raw) }
+func EncodeLaunch(c LaunchContext) ([]byte, error) { return launchctx.EncodeLaunch(c) }
diff --git a/internal/maintenanceguard/types.go b/internal/maintenanceguard/types.go
index a8d6911fb..838729169 100644
--- a/internal/maintenanceguard/types.go
+++ b/internal/maintenanceguard/types.go
@@ -3,8 +3,8 @@
package maintenanceguard
import (
+ "amadan.net/carlos/platform/launchctx/wireid"
"crypto/sha256"
- "encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
@@ -29,20 +29,8 @@ var (
contractRE = regexp.MustCompile(`^[a-z][a-z0-9]*([.-][a-z0-9]+)*$`)
)
-func ValidID(s string) bool {
- if len(s) != 43 {
- return false
- }
- b, e := base64.RawURLEncoding.Strict().DecodeString(s)
- return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
-}
-func ValidDigest(s string) bool {
- if len(s) != 64 {
- return false
- }
- b, e := hex.DecodeString(s)
- return e == nil && len(b) == 32 && hex.EncodeToString(b) == s
-}
+func ValidID(s string) bool { return wireid.ValidID(s) }
+func ValidDigest(s string) bool { return wireid.ValidDigest(s) }
func ValidContract(s string) bool { return len(s) > 0 && len(s) <= 64 && contractRE.MatchString(s) }
func ValidContracts(set []string) bool {
if len(set) > MaxContracts {
diff --git a/launchctx/controlname/names.go b/launchctx/controlname/names.go
new file mode 100644
index 000000000..51c0876fa
--- /dev/null
+++ b/launchctx/controlname/names.go
@@ -0,0 +1,28 @@
+// Package controlname validates identifiers used as control-plane key segments.
+package controlname
+
+import (
+ "regexp"
+ "strings"
+)
+
+var (
+ account = regexp.MustCompile(`^[a-z]{3,16}$`)
+ app = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+ label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+)
+
+func ValidAccount(s string) bool { return account.MatchString(s) }
+func ValidApp(s string) bool { return app.MatchString(s) }
+func ValidLabel(s string) bool { return label.MatchString(s) }
+func ValidHostShape(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, part := range strings.Split(host, ".") {
+ if !ValidLabel(part) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/launchctx/go.mod b/launchctx/go.mod
new file mode 100644
index 000000000..7266080e4
--- /dev/null
+++ b/launchctx/go.mod
@@ -0,0 +1,3 @@
+module amadan.net/carlos/platform/launchctx
+
+go 1.25.0
diff --git a/launchctx/launch.go b/launchctx/launch.go
new file mode 100644
index 000000000..34294fee7
--- /dev/null
+++ b/launchctx/launch.go
@@ -0,0 +1,221 @@
+package launchctx
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "amadan.net/carlos/platform/launchctx/controlname"
+ "amadan.net/carlos/platform/launchctx/wireid"
+)
+
+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 !wireid.ValidID(id) {
+ return false
+ }
+ }
+ for _, digest := range []string{c.RequestDigest, c.RecordIdentity, c.ArtifactDigest, c.EnvironmentEnvelopeDigest, c.InputSHA256} {
+ if !wireid.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" || !wireid.ValidID(c.OperationID) || !wireid.ValidDigest(c.ExpectedStateSHA256) || c.ClosedCheckpoint == nil {
+ return false
+ }
+ p := c.ClosedCheckpoint
+ completed, ok := canonicalStamp(p.CompletedAt)
+ if !ok || completed.After(deadline) || p.Version != 1 || !wireid.ValidID(p.PreparationID) || p.MainLength <= 0 || p.MainLength > 268435456 || !wireid.ValidDigest(p.MainSHA256) || !wireid.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/launchctx/launch_test.go
similarity index 99%
rename from internal/maintenance/launch_test.go
rename to launchctx/launch_test.go
index 51faa7c22..e9d413c19 100644
--- a/internal/maintenance/launch_test.go
+++ b/launchctx/launch_test.go
@@ -1,4 +1,4 @@
-package maintenance
+package launchctx
import (
"bytes"
diff --git a/internal/maintenance/launch_utf8.go b/launchctx/launch_utf8.go
similarity index 97%
rename from internal/maintenance/launch_utf8.go
rename to launchctx/launch_utf8.go
index ba1cdb955..1b9c6c8e5 100644
--- a/internal/maintenance/launch_utf8.go
+++ b/launchctx/launch_utf8.go
@@ -1,4 +1,4 @@
-package maintenance
+package launchctx
import "strconv"
diff --git a/launchctx/mode.go b/launchctx/mode.go
new file mode 100644
index 000000000..caf45da08
--- /dev/null
+++ b/launchctx/mode.go
@@ -0,0 +1,17 @@
+package launchctx
+
+func DecodeOperator(raw []byte) (LaunchContext, error) {
+ c, err := DecodeLaunch(raw)
+ if err != nil || c.Version != 1 {
+ return LaunchContext{}, ErrLaunch
+ }
+ return c, nil
+}
+
+func DecodeChecker(raw []byte) (LaunchContext, error) {
+ c, err := DecodeLaunch(raw)
+ if err != nil || c.Version != 2 {
+ return LaunchContext{}, ErrLaunch
+ }
+ return c, nil
+}
diff --git a/launchctx/mode_test.go b/launchctx/mode_test.go
new file mode 100644
index 000000000..11496f1e7
--- /dev/null
+++ b/launchctx/mode_test.go
@@ -0,0 +1,31 @@
+package launchctx
+
+import "testing"
+
+func TestModeDecodersRefuseOtherLaunchClass(t *testing.T) {
+ v2, c := launchFixture(t)
+ if _, err := DecodeChecker(v2); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DecodeOperator(v2); err == nil {
+ t.Fatal("checker accepted as mutable operator")
+ }
+ c.Version = 1
+ c.ExecutionKind, c.OperationID, c.ExpectedStateSHA256 = "", "", ""
+ c.ClosedCheckpoint = nil
+ v1, err := EncodeLaunch(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DecodeOperator(v1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DecodeChecker(v1); err == nil {
+ t.Fatal("mutable operator accepted as checker")
+ }
+ for _, decode := range []func([]byte) (LaunchContext, error){DecodeOperator, DecodeChecker} {
+ if _, err := decode([]byte(`{}`)); err == nil {
+ t.Fatal("malformed context accepted")
+ }
+ }
+}
diff --git a/internal/maintenance/testdata/generate-launch-vector.py b/launchctx/testdata/generate-launch-vector.py
similarity index 100%
rename from internal/maintenance/testdata/generate-launch-vector.py
rename to launchctx/testdata/generate-launch-vector.py
diff --git a/internal/maintenance/testdata/launch-v2-binding.json b/launchctx/testdata/launch-v2-binding.json
similarity index 100%
rename from internal/maintenance/testdata/launch-v2-binding.json
rename to launchctx/testdata/launch-v2-binding.json
diff --git a/internal/maintenance/testdata/launch-v2.json b/launchctx/testdata/launch-v2.json
similarity index 100%
rename from internal/maintenance/testdata/launch-v2.json
rename to launchctx/testdata/launch-v2.json
diff --git a/launchctx/wireid/id.go b/launchctx/wireid/id.go
new file mode 100644
index 000000000..dcbe172f6
--- /dev/null
+++ b/launchctx/wireid/id.go
@@ -0,0 +1,21 @@
+package wireid
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+)
+
+func ValidID(s string) bool {
+ if len(s) != 43 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+func ValidDigest(s string) bool {
+ if len(s) != 64 {
+ return false
+ }
+ b, e := hex.DecodeString(s)
+ return e == nil && len(b) == 32 && hex.EncodeToString(b) == s
+}