diff --git a/internal/control/envelope.go b/internal/control/envelope.go new file mode 100644 index 0000000..ef892f2 --- /dev/null +++ b/internal/control/envelope.go @@ -0,0 +1,471 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +// Package control implements the D-158 control/audit storage bootstrap: the +// local BootstrapEnvelopeV1 handoff, the typed durable control and audit +// stores, and the resumable first-bootstrap engine. It performs no provider +// I/O itself; provider access arrives through narrow typed ports. +package control + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "time" + + "github.com/thelostorbital/ctrldb/internal/domain" + "github.com/thelostorbital/ctrldb/internal/isolation/bootstrap" + "github.com/thelostorbital/ctrldb/internal/workflow" +) + +const ( + // BootstrapEnvelopeSchemaV1 identifies the sealed local-to-audit handoff. + BootstrapEnvelopeSchemaV1 = "ctrldb.ctrlboard.dev/bootstrap-envelope/v1" + // ApprovalProofSchemaV1 identifies the plan-bound approval record. + ApprovalProofSchemaV1 = "ctrldb.ctrlboard.dev/approval-proof/v1" + // ExpectationAbsent is the only admissible pre-bootstrap observation: every + // desired provider identity was observed absent by the exact preflight. + ExpectationAbsent = "absent" + // MaxEnvelopeBytes bounds any envelope read from disk or a bucket. + MaxEnvelopeBytes = 8 << 20 +) + +var ( + // ErrInvalidEnvelope is returned for a malformed, tampered, noncanonical, + // or cross-binding-inconsistent envelope. + ErrInvalidEnvelope = errors.New("invalid bootstrap envelope") + // ErrInvalidApprovalProof is returned when an approval does not bind the + // exact compiled plan. + ErrInvalidApprovalProof = errors.New("invalid approval proof") + + sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + operationIDPattern = regexp.MustCompile(`^op-[0-9a-f]{16}$`) + planIDPattern = regexp.MustCompile(`^plan-[0-9a-f]{16}$`) + environmentPattern = regexp.MustCompile(`^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + canonicalIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) +) + +// ApprovalProofV1 records the AP-3 approval of one exact compiled plan. It is +// bound to both the compiled document hash and the PlanV1 hash so that no +// semantic change can retain the approval. +type ApprovalProofV1 struct { + Schema string `json:"schema"` + WorkflowID string `json:"workflowId"` + PlanID string `json:"planId"` + PlanDocumentSHA256 string `json:"planDocumentSha256"` + PlanV1Hash string `json:"planV1Hash"` + ApprovalClass domain.ApprovalClass `json:"approvalClass"` + ApprovedBy string `json:"approvedBy"` + ApprovedAt time.Time `json:"approvedAt"` + ValidUntil time.Time `json:"validUntil"` + ProofSHA256 string `json:"proofSha256"` +} + +// NewApprovalProof seals an approval for plan. The caller supplies only the +// approving account and UTC window; every binding value comes from the plan. +func NewApprovalProof(plan bootstrap.CompiledPlan, approvedBy string, approvedAt, validUntil time.Time) (ApprovalProofV1, error) { + sealed := plan.Plan() + proof := ApprovalProofV1{ + Schema: ApprovalProofSchemaV1, WorkflowID: sealed.WorkflowID, PlanID: sealed.PlanID, + PlanDocumentSHA256: plan.DocumentHash(), PlanV1Hash: sealed.PlanHash, ApprovalClass: sealed.ApprovalClass, + ApprovedBy: approvedBy, ApprovedAt: approvedAt, ValidUntil: validUntil, + } + digest, err := hashJSON(proof) + if err != nil { + return ApprovalProofV1{}, invalidApproval("encoding") + } + proof.ProofSHA256 = digest + if err := validateApprovalProof(proof, plan); err != nil { + return ApprovalProofV1{}, err + } + return proof, nil +} + +func validateApprovalProof(proof ApprovalProofV1, plan bootstrap.CompiledPlan) error { + sealed := plan.Plan() + if proof.Schema != ApprovalProofSchemaV1 || proof.WorkflowID != sealed.WorkflowID || proof.PlanID != sealed.PlanID || + proof.PlanDocumentSHA256 != plan.DocumentHash() || proof.PlanV1Hash != sealed.PlanHash || + proof.ApprovalClass != sealed.ApprovalClass || proof.ApprovalClass != domain.ApprovalSecuritySensitive || + proof.ApprovedBy == "" || proof.ApprovedBy != sealed.Principal { + return invalidApproval("plan binding") + } + if !validUTC(proof.ApprovedAt) || !validUTC(proof.ValidUntil) || !proof.ApprovedAt.Before(proof.ValidUntil) || + proof.ApprovedAt.Before(sealed.CreatedAt) || proof.ValidUntil.After(sealed.ExpiresAt) { + return invalidApproval("approval window") + } + copy := proof + copy.ProofSHA256 = "" + digest, err := hashJSON(copy) + if err != nil || digest != proof.ProofSHA256 { + return invalidApproval("integrity") + } + return nil +} + +// ExpectedObservationV1 is one complete pre-bootstrap expectation. The first +// bootstrap is admissible only when every desired identity was absent. +type ExpectedObservationV1 struct { + ResourceID string `json:"resourceId"` + Kind bootstrap.ResourceKind `json:"kind"` + Name string `json:"name"` + Project string `json:"project"` + Location string `json:"location"` + ProviderID string `json:"providerId"` + Expectation string `json:"expectation"` + DesiredStateFingerprint string `json:"desiredStateFingerprint"` +} + +type envelopePayloadV1 struct { + WorkflowID string `json:"workflowId"` + OperationID string `json:"operationId"` + Environment string `json:"environment"` + Project string `json:"project"` + Account string `json:"account"` + PlanID string `json:"planId"` + PlanDocumentSHA256 string `json:"planDocumentSha256"` + PlanV1Hash string `json:"planV1Hash"` + EnvelopeBindingSHA256 string `json:"envelopeBindingSha256"` + ContractHash string `json:"contractHash"` + Plan json.RawMessage `json:"plan"` + Approval ApprovalProofV1 `json:"approval"` + ObservationRevision string `json:"observationRevision"` + ExpectedObservations []ExpectedObservationV1 `json:"expectedObservations"` + AuditBucket string `json:"auditBucket"` + AuditBucketLocation string `json:"auditBucketLocation"` + ControlBucket string `json:"controlBucket"` + FirstJournalObjectName string `json:"firstJournalObjectName"` + FirstJournalEntry json.RawMessage `json:"firstJournalEntry"` + SealedAt time.Time `json:"sealedAt"` +} + +type envelopeWireV1 struct { + SchemaVersion string `json:"schemaVersion"` + Envelope envelopePayloadV1 `json:"envelope"` + EnvelopeSHA256 string `json:"envelopeSha256"` +} + +// EnvelopeSeed contains the caller-owned inputs for sealing. Everything else +// in the envelope is derived from the immutable compiled plan. +type EnvelopeSeed struct { + Plan bootstrap.CompiledPlan + OperationID string + Approval ApprovalProofV1 + FirstJournalEntry domain.JournalEntry + SealedAt time.Time +} + +// BootstrapEnvelopeV1 is immutable. It carries no credential, token, or +// provider output: every field is typed and derived from the compiled plan, +// the approval proof, or the closed journal schema. +type BootstrapEnvelopeV1 struct { + payload envelopePayloadV1 + hash string + plan bootstrap.CompiledPlan + entry domain.JournalEntry +} + +// SealBootstrapEnvelope validates every cross-binding and seals the envelope. +func SealBootstrapEnvelope(seed EnvelopeSeed) (BootstrapEnvelopeV1, error) { + planJSON, err := seed.Plan.CanonicalJSON() + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("compiled plan") + } + entryJSON, err := workflow.EncodeJournalEntry(seed.FirstJournalEntry) + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("first journal entry") + } + sealed := seed.Plan.Plan() + desired := seed.Plan.DesiredState() + journalName, err := JournalEntryObjectName(sealed.Environment, seed.FirstJournalEntry) + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("first journal object name") + } + payload := envelopePayloadV1{ + WorkflowID: sealed.WorkflowID, OperationID: seed.OperationID, Environment: sealed.Environment, + Project: sealed.ProjectID, Account: sealed.Principal, PlanID: sealed.PlanID, + PlanDocumentSHA256: seed.Plan.DocumentHash(), PlanV1Hash: sealed.PlanHash, + EnvelopeBindingSHA256: seed.Plan.Binding().BindingSHA256, ContractHash: seed.Plan.ExecutionContract().Digest(), + Plan: planJSON, Approval: seed.Approval, ObservationRevision: seed.Plan.Binding().ObservationRevision, + ExpectedObservations: expectedObservations(seed.Plan), AuditBucket: desired.AuditBucket, + AuditBucketLocation: desired.Region, ControlBucket: desired.ControlBucket, + FirstJournalObjectName: journalName.String(), FirstJournalEntry: entryJSON, SealedAt: seed.SealedAt, + } + envelope := BootstrapEnvelopeV1{payload: payload, plan: seed.Plan, entry: seed.FirstJournalEntry} + if err := envelope.validate(); err != nil { + return BootstrapEnvelopeV1{}, err + } + envelope.hash, err = hashJSON(payload) + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("hash") + } + return envelope, nil +} + +func expectedObservations(plan bootstrap.CompiledPlan) []ExpectedObservationV1 { + resources := plan.DesiredResources() + result := make([]ExpectedObservationV1, len(resources)) + for index, resource := range resources { + result[index] = ExpectedObservationV1{ + ResourceID: resource.ID, Kind: resource.Kind, Name: resource.Name, Project: resource.Project, + Location: resource.Location, ProviderID: resource.ProviderID, Expectation: ExpectationAbsent, + DesiredStateFingerprint: resource.DesiredStateFingerprint, + } + } + return result +} + +func (envelope BootstrapEnvelopeV1) validate() error { + payload := envelope.payload + sealed := envelope.plan.Plan() + desired := envelope.plan.DesiredState() + if payload.WorkflowID != bootstrap.WorkflowID || sealed.WorkflowID != bootstrap.WorkflowID || + !operationIDPattern.MatchString(payload.OperationID) || !environmentPattern.MatchString(payload.Environment) || + payload.Environment != sealed.Environment || payload.Project != sealed.ProjectID || payload.Account != sealed.Principal || + !planIDPattern.MatchString(payload.PlanID) || payload.PlanID != sealed.PlanID || + payload.PlanDocumentSHA256 != envelope.plan.DocumentHash() || payload.PlanV1Hash != sealed.PlanHash || + payload.EnvelopeBindingSHA256 != envelope.plan.Binding().BindingSHA256 || + payload.ContractHash != envelope.plan.ExecutionContract().Digest() || + payload.ObservationRevision != envelope.plan.Binding().ObservationRevision { + return invalidEnvelope("plan binding") + } + if payload.AuditBucket != desired.AuditBucket || payload.ControlBucket != desired.ControlBucket || + payload.AuditBucketLocation != desired.Region || payload.AuditBucket == payload.ControlBucket || + payload.AuditBucket == "" || payload.ControlBucket == "" || payload.AuditBucketLocation == "" { + return invalidEnvelope("bucket identities") + } + if !equalCanonicalValue(payload.ExpectedObservations, expectedObservations(envelope.plan)) { + return invalidEnvelope("expected observations") + } + if err := validateApprovalProof(payload.Approval, envelope.plan); err != nil { + return err + } + if !validUTC(payload.SealedAt) || payload.SealedAt.Before(sealed.CreatedAt) || !payload.SealedAt.Before(sealed.ExpiresAt) || + payload.SealedAt.Before(payload.Approval.ApprovedAt) || !payload.SealedAt.Before(payload.Approval.ValidUntil) { + return invalidEnvelope("seal time") + } + entry := envelope.entry + if entry.OperationID != payload.OperationID || entry.PlanID != payload.PlanID || entry.ContractHash != payload.ContractHash || + entry.Sequence != 1 || entry.Kind != domain.JournalEntryTransition || entry.OperationState != domain.OperationDiscover || + entry.RecordedAt.Before(sealed.CreatedAt) || entry.RecordedAt.After(payload.SealedAt) { + return invalidEnvelope("first journal entry binding") + } + journalName, err := JournalEntryObjectName(payload.Environment, entry) + if err != nil || journalName.String() != payload.FirstJournalObjectName { + return invalidEnvelope("first journal object name") + } + return nil +} + +// ErrEnvelopeExpired is returned when the approval or plan window no longer +// admits a mutation at the evaluated time. +var ErrEnvelopeExpired = errors.New("bootstrap envelope authorization expired or not yet valid") + +// validAt requires now to fall inside both the approval window and the plan +// validity window. Observation freshness is revalidated by the gateway. +func (envelope BootstrapEnvelopeV1) validAt(now time.Time) error { + if envelope.hash == "" { + return invalidEnvelope("unsealed") + } + sealed := envelope.plan.Plan() + approval := envelope.payload.Approval + if !validUTC(now) || now.Before(approval.ApprovedAt) || !now.Before(approval.ValidUntil) || + now.Before(sealed.CreatedAt) || !now.Before(sealed.ExpiresAt) { + return ErrEnvelopeExpired + } + return nil +} + +// ParseBootstrapEnvelope accepts only the canonical encoding. Duplicate, +// unknown, null, or trailing fields, a hash mismatch, or any cross-binding +// failure rejects the document. +func ParseBootstrapEnvelope(encoded []byte) (BootstrapEnvelopeV1, error) { + if len(encoded) == 0 || len(encoded) > MaxEnvelopeBytes || !json.Valid(encoded) { + return BootstrapEnvelopeV1{}, invalidEnvelope("document") + } + if err := rejectDuplicateKeys(encoded); err != nil { + return BootstrapEnvelopeV1{}, err + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + var wire envelopeWireV1 + if err := decoder.Decode(&wire); err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("document schema") + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return BootstrapEnvelopeV1{}, invalidEnvelope("trailing data") + } + if wire.SchemaVersion != BootstrapEnvelopeSchemaV1 || !sha256Pattern.MatchString(wire.EnvelopeSHA256) { + return BootstrapEnvelopeV1{}, invalidEnvelope("schema or hash") + } + digest, err := hashJSON(wire.Envelope) + if err != nil || digest != wire.EnvelopeSHA256 { + return BootstrapEnvelopeV1{}, invalidEnvelope("integrity") + } + plan, err := bootstrap.ParseCompiledPlan(wire.Envelope.Plan) + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("embedded plan") + } + entry, err := workflow.DecodeJournalEntry(wire.Envelope.FirstJournalEntry) + if err != nil { + return BootstrapEnvelopeV1{}, invalidEnvelope("embedded journal entry") + } + envelope := BootstrapEnvelopeV1{payload: wire.Envelope, hash: wire.EnvelopeSHA256, plan: plan, entry: entry} + if err := envelope.validate(); err != nil { + return BootstrapEnvelopeV1{}, err + } + canonical, err := envelope.CanonicalJSON() + if err != nil || !bytes.Equal(canonical, encoded) { + return BootstrapEnvelopeV1{}, invalidEnvelope("noncanonical encoding") + } + return envelope, nil +} + +// CanonicalJSON returns the compact hash-bound encoding. +func (envelope BootstrapEnvelopeV1) CanonicalJSON() ([]byte, error) { + if envelope.hash == "" { + return nil, invalidEnvelope("unsealed") + } + if err := envelope.validate(); err != nil { + return nil, err + } + digest, err := hashJSON(envelope.payload) + if err != nil || digest != envelope.hash { + return nil, invalidEnvelope("integrity") + } + return json.Marshal(envelopeWireV1{SchemaVersion: BootstrapEnvelopeSchemaV1, Envelope: envelope.payload, EnvelopeSHA256: envelope.hash}) +} + +// SHA256 returns the envelope hash which every retry must reproduce exactly. +func (envelope BootstrapEnvelopeV1) SHA256() string { return envelope.hash } + +func (envelope BootstrapEnvelopeV1) OperationID() string { return envelope.payload.OperationID } +func (envelope BootstrapEnvelopeV1) Environment() string { return envelope.payload.Environment } +func (envelope BootstrapEnvelopeV1) Project() string { return envelope.payload.Project } +func (envelope BootstrapEnvelopeV1) Account() string { return envelope.payload.Account } +func (envelope BootstrapEnvelopeV1) PlanID() string { return envelope.payload.PlanID } +func (envelope BootstrapEnvelopeV1) PlanDocumentSHA256() string { + return envelope.payload.PlanDocumentSHA256 +} +func (envelope BootstrapEnvelopeV1) PlanV1Hash() string { return envelope.payload.PlanV1Hash } +func (envelope BootstrapEnvelopeV1) EnvelopeBindingSHA256() string { + return envelope.payload.EnvelopeBindingSHA256 +} +func (envelope BootstrapEnvelopeV1) ContractHash() string { return envelope.payload.ContractHash } +func (envelope BootstrapEnvelopeV1) Approval() ApprovalProofV1 { return envelope.payload.Approval } +func (envelope BootstrapEnvelopeV1) ObservationRevision() string { + return envelope.payload.ObservationRevision +} +func (envelope BootstrapEnvelopeV1) AuditBucket() string { return envelope.payload.AuditBucket } +func (envelope BootstrapEnvelopeV1) AuditBucketLocation() string { + return envelope.payload.AuditBucketLocation +} +func (envelope BootstrapEnvelopeV1) ControlBucket() string { return envelope.payload.ControlBucket } +func (envelope BootstrapEnvelopeV1) SealedAt() time.Time { return envelope.payload.SealedAt } +func (envelope BootstrapEnvelopeV1) Plan() bootstrap.CompiledPlan { return envelope.plan } +func (envelope BootstrapEnvelopeV1) FirstJournalEntry() domain.JournalEntry { + return envelope.entry +} +func (envelope BootstrapEnvelopeV1) ExpectedObservations() []ExpectedObservationV1 { + return append([]ExpectedObservationV1(nil), envelope.payload.ExpectedObservations...) +} + +// FirstJournalObjectName returns the audit object name of the first entry. +func (envelope BootstrapEnvelopeV1) FirstJournalObjectName() AuditObjectName { + return AuditObjectName{value: envelope.payload.FirstJournalObjectName} +} + +// FirstJournalEntryJSON returns the exact bytes uploaded create-only. +func (envelope BootstrapEnvelopeV1) FirstJournalEntryJSON() []byte { + return append([]byte(nil), envelope.payload.FirstJournalEntry...) +} + +func rejectDuplicateKeys(encoded []byte) error { + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := consumeUniqueValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return invalidEnvelope("trailing data") + } + return nil +} + +func consumeUniqueValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil || token == nil { + return invalidEnvelope("malformed or null JSON") + } + delimiter, composite := token.(json.Delim) + if !composite { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + key, ok := keyToken.(string) + if err != nil || !ok { + return invalidEnvelope("object key") + } + if _, exists := seen[key]; exists { + return invalidEnvelope("duplicate field") + } + seen[key] = struct{}{} + if err := consumeUniqueValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + case '[': + for decoder.More() { + if err := consumeUniqueValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + default: + return invalidEnvelope("JSON delimiter") + } + if err != nil { + return invalidEnvelope("malformed JSON") + } + return nil +} + +func hashJSON(value any) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]), nil +} + +func equalCanonicalValue(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON) +} + +func validUTC(value time.Time) bool { + if value.IsZero() { + return false + } + _, offset := value.Zone() + return offset == 0 +} + +func invalidEnvelope(field string) error { + return fmt.Errorf("%w: %s", ErrInvalidEnvelope, field) +} + +func invalidApproval(field string) error { + return fmt.Errorf("%w: %s", ErrInvalidApprovalProof, field) +} diff --git a/internal/control/envelope_test.go b/internal/control/envelope_test.go new file mode 100644 index 0000000..990149a --- /dev/null +++ b/internal/control/envelope_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/thelostorbital/ctrldb/internal/domain" +) + +func TestEnvelopeSealParseRoundTripIsCanonical(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + encoded, err := envelope.CanonicalJSON() + if err != nil { + t.Fatalf("CanonicalJSON() error: %v", err) + } + parsed, err := ParseBootstrapEnvelope(encoded) + if err != nil { + t.Fatalf("ParseBootstrapEnvelope() error: %v", err) + } + reencoded, err := parsed.CanonicalJSON() + if err != nil || !bytes.Equal(reencoded, encoded) || parsed.SHA256() != envelope.SHA256() { + t.Fatalf("round trip changed the envelope (err=%v)", err) + } + if parsed.OperationID() != fixtureOperationID || parsed.AuditBucket() == parsed.ControlBucket() || + parsed.AuditBucketLocation() != fixtureRegion || parsed.Plan().DocumentHash() != envelope.Plan().DocumentHash() || + parsed.FirstJournalEntry().Sequence != 1 || !strings.HasPrefix(parsed.FirstJournalObjectName().String(), "operations/disposable-test/"+fixtureOperationID+"/steps/00000000000000000001-state-discover.json") { + t.Fatalf("parsed accessors = %#v", parsed.payload) + } + observations := parsed.ExpectedObservations() + if len(observations) != len(parsed.Plan().DesiredResources()) { + t.Fatalf("expected observations = %d", len(observations)) + } + for _, observation := range observations { + if observation.Expectation != ExpectationAbsent || observation.ProviderID == "" { + t.Fatalf("observation = %#v", observation) + } + } + // Second seal of the identical seed reproduces the identical hash. + again, err := SealBootstrapEnvelope(fixtureSeed(t)) + if err != nil || again.SHA256() != envelope.SHA256() { + t.Fatalf("seal is not deterministic (err=%v)", err) + } + // No credential-shaped field names or provider output are present. + lower := strings.ToLower(string(encoded)) + for _, forbidden := range []string{"token", "password", "secret", "authorization", "selflink", "argv", "command\"", "gcloud"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("envelope contains forbidden material %q", forbidden) + } + } +} + +func TestEnvelopeParseRejectsTamperingAndAmbiguity(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + encoded, _ := envelope.CanonicalJSON() + + flipped := append([]byte(nil), encoded...) + index := bytes.Index(flipped, []byte(`"auditBucket":"`)) + len(`"auditBucket":"`) + flipped[index] ^= 0x01 + hashIndex := bytes.LastIndex(encoded, []byte(`"envelopeSha256":"`)) + len(`"envelopeSha256":"`) + hashFlipped := append([]byte(nil), encoded...) + if hashFlipped[hashIndex] == '0' { + hashFlipped[hashIndex] = '1' + } else { + hashFlipped[hashIndex] = '0' + } + duplicate := bytes.Replace(encoded, []byte(`"envelope":{`), []byte(`"envelope":{"workflowId":"WF-TEST-01",`), 1) + unknown := bytes.Replace(encoded, []byte(`"schemaVersion":`), []byte(`"extra":1,"schemaVersion":`), 1) + spaced := bytes.Replace(encoded, []byte(`"schemaVersion":`), []byte(`"schemaVersion": `), 1) + tests := map[string][]byte{ + "empty": nil, "null": []byte("null"), "trailing": append(append([]byte(nil), encoded...), '\n', '{', '}'), + "payload byte flipped": flipped, "hash flipped": hashFlipped, "duplicate key": duplicate, + "unknown field": unknown, "noncanonical whitespace": spaced, "oversized": bytes.Repeat([]byte("["), MaxEnvelopeBytes+1), + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + if _, err := ParseBootstrapEnvelope(input); !errors.Is(err, ErrInvalidEnvelope) { + t.Fatalf("ParseBootstrapEnvelope() error = %v, want ErrInvalidEnvelope", err) + } + }) + } +} + +func TestEnvelopeSealRejectsCrossBindingFailures(t *testing.T) { + t.Parallel() + base := fixtureSeed(t) + tests := []struct { + name string + mutate func(seed *EnvelopeSeed) + want error + }{ + {"operation id", func(seed *EnvelopeSeed) { seed.OperationID = "op-XYZ" }, ErrInvalidEnvelope}, + {"journal operation mismatch", func(seed *EnvelopeSeed) { seed.FirstJournalEntry.OperationID = "op-fedcba9876543210" }, ErrInvalidEnvelope}, + {"journal contract mismatch", func(seed *EnvelopeSeed) { seed.FirstJournalEntry.ContractHash = repeatHex("b") }, ErrInvalidEnvelope}, + {"journal not first", func(seed *EnvelopeSeed) { seed.FirstJournalEntry.Sequence = 2 }, ErrInvalidEnvelope}, + {"journal not discover", func(seed *EnvelopeSeed) { seed.FirstJournalEntry.OperationState = domain.OperationValidate }, ErrInvalidEnvelope}, + {"journal after seal", func(seed *EnvelopeSeed) { seed.FirstJournalEntry.RecordedAt = seed.SealedAt.Add(time.Second) }, ErrInvalidEnvelope}, + {"seal before plan", func(seed *EnvelopeSeed) { seed.SealedAt = fixtureNow.Add(-time.Second) }, ErrInvalidEnvelope}, + {"seal after approval expiry", func(seed *EnvelopeSeed) { seed.SealedAt = seed.Approval.ValidUntil }, ErrInvalidEnvelope}, + {"seal not UTC", func(seed *EnvelopeSeed) { seed.SealedAt = seed.SealedAt.In(time.FixedZone("X", 3600)) }, ErrInvalidEnvelope}, + {"approval plan hash", func(seed *EnvelopeSeed) { seed.Approval.PlanV1Hash = repeatHex("c") }, ErrInvalidApprovalProof}, + {"approval document hash", func(seed *EnvelopeSeed) { seed.Approval.PlanDocumentSHA256 = repeatHex("c") }, ErrInvalidApprovalProof}, + {"approval account", func(seed *EnvelopeSeed) { seed.Approval.ApprovedBy = "other@example.invalid" }, ErrInvalidApprovalProof}, + {"approval class", func(seed *EnvelopeSeed) { seed.Approval.ApprovalClass = domain.ApprovalRead }, ErrInvalidApprovalProof}, + {"approval proof hash", func(seed *EnvelopeSeed) { seed.Approval.ProofSHA256 = repeatHex("d") }, ErrInvalidApprovalProof}, + {"approval after plan expiry", func(seed *EnvelopeSeed) { seed.Approval.ValidUntil = fixtureNow.Add(2 * time.Hour) }, ErrInvalidApprovalProof}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + seed := base + test.mutate(&seed) + if _, err := SealBootstrapEnvelope(seed); !errors.Is(err, test.want) { + t.Fatalf("SealBootstrapEnvelope() error = %v, want %v", err, test.want) + } + }) + } +} + +func TestNewApprovalProofRejectsWindowsOutsidePlan(t *testing.T) { + t.Parallel() + plan := fixturePlan(t) + if _, err := NewApprovalProof(plan, fixtureAccount, fixtureNow.Add(-time.Second), fixtureNow.Add(time.Minute)); !errors.Is(err, ErrInvalidApprovalProof) { + t.Fatalf("approval before plan creation accepted: %v", err) + } + if _, err := NewApprovalProof(plan, "someone-else@example.invalid", fixtureNow, fixtureNow.Add(time.Minute)); !errors.Is(err, ErrInvalidApprovalProof) { + t.Fatalf("approval by a non-principal accepted: %v", err) + } + proof, err := NewApprovalProof(plan, fixtureAccount, fixtureNow, fixtureNow.Add(time.Hour)) + if err != nil { + t.Fatalf("NewApprovalProof() error: %v", err) + } + encoded, _ := json.Marshal(proof) + if !strings.Contains(string(encoded), `"approvalClass":"security-sensitive"`) && !strings.Contains(string(encoded), `"approvalClass":"`) { + t.Fatalf("approval encoding = %s", encoded) + } +} diff --git a/internal/control/fixture_test.go b/internal/control/fixture_test.go new file mode 100644 index 0000000..387d415 --- /dev/null +++ b/internal/control/fixture_test.go @@ -0,0 +1,222 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/thelostorbital/ctrldb/internal/config" + "github.com/thelostorbital/ctrldb/internal/domain" + "github.com/thelostorbital/ctrldb/internal/isolation/bootstrap" + "github.com/thelostorbital/ctrldb/internal/observation" +) + +const ( + fixtureAccount = "operator@example.invalid" + fixtureProject = "example-project" + fixtureRegion = "us-central1" + fixtureZone = "us-central1-a" + fixtureOperationID = "op-0123456789abcdef" + fixturePlanID = "plan-0123456789abcdef" +) + +var fixtureNow = time.Date(2026, 9, 9, 12, 1, 0, 0, time.UTC) + +// fixturePermissions mirrors the M1-04 step registry exactly; a provider +// permission prover would emit this list. +var fixturePermissions = []struct { + step string + permissions []string +}{ + {"k1-audit-bootstrap", []string{"storage.buckets.create", "storage.buckets.get", "storage.objects.create", "storage.objects.get"}}, + {"k1-retention-lock", []string{"storage.buckets.get", "storage.buckets.update"}}, + {"k2-control-bucket", []string{"storage.buckets.create", "storage.buckets.get", "storage.buckets.update"}}, + {"k3-bucket-iam", []string{"storage.buckets.getIamPolicy", "storage.buckets.setIamPolicy"}}, + {"k4-seed-control", []string{"storage.objects.create", "storage.objects.get"}}, + {"k5-lock-round-trip", []string{"storage.objects.create", "storage.objects.get", "storage.objects.update"}}, + {"t1-network", []string{"compute.networks.create", "compute.networks.get"}}, + {"t2-subnet", []string{"compute.subnetworks.create", "compute.subnetworks.get"}}, + {"t3-nat", []string{"compute.routers.create", "compute.routers.get", "compute.routers.update"}}, + {"t4-firewall", []string{"compute.firewalls.create", "compute.firewalls.get"}}, + {"t5-identities", []string{"iam.roles.create", "iam.roles.get", "iam.roles.update", "iam.serviceAccounts.create", "iam.serviceAccounts.get", "iam.serviceAccounts.setIamPolicy", "resourcemanager.projects.getIamPolicy", "resourcemanager.projects.setIamPolicy"}}, + {"t6-control-prefix", []string{"storage.buckets.getIamPolicy", "storage.buckets.setIamPolicy"}}, + {"t7-nightly-wipe", []string{"cloudscheduler.jobs.create", "cloudscheduler.jobs.get", "iam.serviceAccounts.actAs", "run.jobs.create", "run.jobs.get", "run.jobs.run"}}, + {"t8-isolation-gate", []string{"cloudscheduler.jobs.get", "compute.firewalls.get", "compute.networks.get", "compute.routers.get", "compute.subnetworks.get", "iam.roles.get", "iam.serviceAccounts.get", "iam.serviceAccounts.getIamPolicy", "resourcemanager.projects.getIamPolicy", "run.jobs.get", "storage.buckets.get", "storage.buckets.getIamPolicy"}}, +} + +func fixturePlan(t *testing.T) bootstrap.CompiledPlan { + t.Helper() + request := bootstrap.CompileRequest{ + Configuration: fixtureConfiguration(t), Preflight: fixturePreflight(t), PlanID: fixturePlanID, + CreatedAt: fixtureNow, ExpiresAt: fixtureNow.Add(time.Hour), + LocalPolicyHash: repeatHex("a"), ApprovedPolicyHash: repeatHex("a"), + Pricing: bootstrap.PricingEvidence{ + MachineType: "e2-medium", Region: fixtureRegion, Zone: fixtureZone, GuestCPUs: 2, MemoryMiB: 4096, + DiskGiB: 100, Instances: 3, LifetimeSeconds: int64((8 * time.Hour) / time.Second), EstimatedRunMicros: 5_000_000, + Currency: "USD", PriceTableDate: "2026-09-09", Schema: bootstrap.PricingSchemaV1, + ObservedAt: fixtureNow.Add(-time.Minute), ValidUntil: fixtureNow.Add(4 * time.Minute), + }, + } + request.Pricing.Revision = fixtureRevision(t, request.Pricing) + grants := make([]bootstrap.PermissionGrant, 0) + for _, step := range fixturePermissions { + for _, permission := range step.permissions { + grants = append(grants, bootstrap.PermissionGrant{StepID: step.step, Identity: domain.IdentityHuman, Permission: permission, Granted: true}) + } + } + request.Permissions = bootstrap.PermissionEvidence{ + Account: fixtureAccount, Project: fixtureProject, Schema: bootstrap.PermissionEvidenceSchemaV1, + ObservedAt: fixtureNow.Add(-time.Minute), ValidUntil: fixtureNow.Add(4 * time.Minute), Grants: grants, + } + request.Permissions.Revision = fixtureRevision(t, request.Permissions) + plan, err := bootstrap.Compile(request) + if err != nil { + t.Fatalf("bootstrap.Compile() unexpected error: %v", err) + } + return plan +} + +func fixtureRevision(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal(revision input) unexpected error: %v", err) + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]) +} + +func fixtureConfiguration(t *testing.T) config.HarnessConfiguration { + t.Helper() + encoded, err := os.ReadFile("../config/testdata/manifest-v1alpha1.yaml") + if err != nil { + t.Fatalf("read manifest fixture: %v", err) + } + envelope, err := config.DecodeManifestEnvelope(encoded) + if err != nil { + t.Fatalf("config.DecodeManifestEnvelope() unexpected error: %v", err) + } + var manifest map[string]any + if err := json.Unmarshal(envelope.JSON(), &manifest); err != nil { + t.Fatalf("json.Unmarshal(manifest) unexpected error: %v", err) + } + metadata := manifest["metadata"].(map[string]any) + metadata["name"] = "disposable-test" + metadata["class"] = "disposable" + spec := manifest["spec"].(map[string]any) + spec["testIsolation"] = map[string]any{ + "namePrefix": config.TestResourcePrefix, + "labels": map[string]any{"managed-by": "ctrldb", "environment": "disposable", "purpose": "test"}, + "operatorServiceAccount": "ctrldb-test-operator@example-project.iam.gserviceaccount.com", + "destructiveServiceAccount": "ctrldb-test-destructive@example-project.iam.gserviceaccount.com", + "network": map[string]any{"vpc": "ctrldb-test-vpc", "subnet": "ctrldb-test-subnet", "cidr": "10.40.0.0/24", "nat": "ctrldb-test-nat"}, + "ciPrincipal": "principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/example-pool/attribute.repository/example-org/ctrldb", + "caps": map[string]any{"maxMachineType": "e2-medium", "maxDiskGiB": 100, "maxInstances": 3, "maxLifetime": "8h", "maxEstimatedUSDPerRun": 25}, + "monitoringTests": "manual-only", + } + spec["host"].(map[string]any)["serviceAccount"] = "ctrldb-test-vm@example-project.iam.gserviceaccount.com" + reconciler := spec["reconciler"].(map[string]any) + reconciler["schedulerJob"] = "ctrldb-test-wipe-schedule" + reconciler["runJob"] = "ctrldb-test-wipe" + reconciler["serviceAccount"] = "ctrldb-test-wipe@example-project.iam.gserviceaccount.com" + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("json.Marshal(manifest) unexpected error: %v", err) + } + document, err := config.DecodeManifest(rewritten) + if err != nil { + t.Fatalf("config.DecodeManifest() unexpected error: %v", err) + } + configuration, err := config.HarnessConfigurationFromManifest(document) + if err != nil { + t.Fatalf("config.HarnessConfigurationFromManifest() unexpected error: %v", err) + } + return configuration +} + +func fixturePreflight(t *testing.T) observation.HarnessPreflight { + t.Helper() + services := make([]observation.APIService, 0) + for _, service := range bootstrap.RequiredAPIs() { + services = append(services, observation.APIService{Name: service, State: observation.APIEnabled}) + } + seed := observation.Seed{ + Account: fixtureAccount, Project: fixtureProject, Region: fixtureRegion, Zone: fixtureZone, + GcloudVersion: observation.SupportedGcloudVersion, CompletenessPolicy: observation.GcloudCompletenessPolicy, + ObservedAt: fixtureNow.Add(-time.Minute), ValidUntil: fixtureNow.Add(4 * time.Minute), + Schemas: observation.RequiredSchemas(), + Regions: []observation.Region{{Name: fixtureRegion, Availability: observation.AvailabilityUp, ProviderID: "projects/" + fixtureProject + "/regions/" + fixtureRegion}}, + Zones: []observation.Zone{{Name: fixtureZone, Region: fixtureRegion, Availability: observation.AvailabilityUp, ProviderID: "projects/" + fixtureProject + "/zones/" + fixtureZone}}, + MachineTypes: []observation.MachineType{{ + Name: "e2-medium", Zone: fixtureZone, GuestCPUs: 2, MemoryMiB: 4096, + ProviderID: "projects/" + fixtureProject + "/zones/" + fixtureZone + "/machineTypes/e2-medium", + }}, + APIs: services, Exhaustive: true, + } + preflight, err := observation.NewHarnessPreflight(seed) + if err != nil { + t.Fatalf("observation.NewHarnessPreflight() unexpected error: %v", err) + } + return preflight +} + +func fixtureJournalEntry(plan bootstrap.CompiledPlan, recordedAt time.Time) domain.JournalEntry { + return domain.JournalEntry{ + Schema: domain.JournalSchemaV1, OperationID: fixtureOperationID, PlanID: plan.Plan().PlanID, + ContractHash: plan.ExecutionContract().Digest(), Sequence: 1, Kind: domain.JournalEntryTransition, + RecordedAt: recordedAt, OperationState: domain.OperationDiscover, + } +} + +func fixtureSeed(t *testing.T) EnvelopeSeed { + t.Helper() + plan := fixturePlan(t) + approval, err := NewApprovalProof(plan, fixtureAccount, fixtureNow.Add(time.Minute), fixtureNow.Add(30*time.Minute)) + if err != nil { + t.Fatalf("NewApprovalProof() unexpected error: %v", err) + } + return EnvelopeSeed{ + Plan: plan, OperationID: fixtureOperationID, Approval: approval, + FirstJournalEntry: fixtureJournalEntry(plan, fixtureNow.Add(2*time.Minute)), SealedAt: fixtureNow.Add(2 * time.Minute), + } +} + +func fixtureEnvelope(t *testing.T) BootstrapEnvelopeV1 { + t.Helper() + envelope, err := SealBootstrapEnvelope(fixtureSeed(t)) + if err != nil { + t.Fatalf("SealBootstrapEnvelope() unexpected error: %v", err) + } + return envelope +} + +func fixtureStateDirectory(t *testing.T) StateDirectory { + t.Helper() + path, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve temp directory: %v", err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatalf("chmod state directory: %v", err) + } + directory, err := NewStateDirectory(path) + if err != nil { + t.Fatalf("NewStateDirectory() unexpected error: %v", err) + } + return directory +} + +func repeatHex(character string) string { + result := make([]byte, 64) + for index := range result { + result[index] = character[0] + } + return string(result) +} diff --git a/internal/control/handoff.go b/internal/control/handoff.go new file mode 100644 index 0000000..0e3a8b2 --- /dev/null +++ b/internal/control/handoff.go @@ -0,0 +1,736 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "time" +) + +const ( + // HandoffRecordSchemaV1 identifies one local D-158 progress record. + HandoffRecordSchemaV1 = "ctrldb.ctrlboard.dev/bootstrap-handoff-record/v1" + // AuditRetentionSeconds is the D-111 Bucket Lock retention (365 days). + AuditRetentionSeconds int64 = 365 * 24 * 60 * 60 + // AuditArchiveAfterDays is the D-088 class transition with no delete rule. + AuditArchiveAfterDays int64 = 365 + // AuditStorageClass is the mandated default class. + AuditStorageClass = "STANDARD" + // PublicAccessPreventionEnforced is the only admissible PAP value. + PublicAccessPreventionEnforced = "enforced" + + maxHandoffRecordBytes = 64 << 10 +) + +var ( + // ErrBucketNameConflict is the explicit create-time precondition failure: + // the global bucket namespace already holds the name. It is never a claim + // about absence. + ErrBucketNameConflict = errors.New("bucket-conflict-guarded precondition failed: audit bucket name is taken in the global namespace") + // ErrPartialBootstrapBlocked is returned when an unrecorded or conflicting + // partial audit bucket must be recovered explicitly. + ErrPartialBootstrapBlocked = errors.New("partial audit bootstrap blocked for explicit recovery") + // ErrHandoffOutOfOrder is returned when a caller asks for K1b before K1a + // is durably verified. + ErrHandoffOutOfOrder = errors.New("audit handoff phase out of order") + // ErrInvalidHandoffRecord is returned for a malformed or inconsistent local + // progress record chain. + ErrInvalidHandoffRecord = errors.New("invalid bootstrap handoff record") + // ErrInvalidHandoffPort is returned when a port returns an impossible + // observation. + ErrInvalidHandoffPort = errors.New("audit bucket port returned an inconsistent observation") +) + +// HandoffPhase is the closed, ordered D-158 boundary set. +type HandoffPhase string + +const ( + PhaseEnvelopeSealed HandoffPhase = "envelope-sealed" + PhaseAuditBucketClaimed HandoffPhase = "audit-bucket-create-claimed" + PhaseAuditBucketCreated HandoffPhase = "audit-bucket-created" + PhaseEnvelopeUploaded HandoffPhase = "envelope-uploaded" + PhaseJournalUploaded HandoffPhase = "journal-uploaded" + PhaseLifecycleConfigured HandoffPhase = "lifecycle-configured" + PhaseHandoffVerified HandoffPhase = "handoff-verified" + PhaseRetentionConfigured HandoffPhase = "retention-configured" + PhaseRetentionLockClaimed HandoffPhase = "retention-lock-claimed" + PhaseRetentionLocked HandoffPhase = "retention-locked" + phaseOrderUnknown = -1 +) + +var handoffPhaseOrder = [...]HandoffPhase{ + PhaseEnvelopeSealed, PhaseAuditBucketClaimed, PhaseAuditBucketCreated, PhaseEnvelopeUploaded, + PhaseJournalUploaded, PhaseLifecycleConfigured, PhaseHandoffVerified, PhaseRetentionConfigured, + PhaseRetentionLockClaimed, PhaseRetentionLocked, +} + +func (phase HandoffPhase) order() int { + for index, candidate := range handoffPhaseOrder { + if candidate == phase { + return index + } + } + return phaseOrderUnknown +} + +// BucketIdentity is the exact desired bucket identity from the approved plan. +type BucketIdentity struct { + Name string `json:"name"` + Project string `json:"project"` + Location string `json:"location"` +} + +// BucketState is a complete typed bucket observation. A port must fill every +// field from machine-readable provider output; it never infers a default. +type BucketState struct { + Identity BucketIdentity + UniformBucketLevelAccess bool + PublicAccessPrevention string + StorageClass string + Versioning bool + RetentionSeconds int64 + RetentionLocked bool + LifecycleArchiveAfterDay int64 + LifecycleDeleteRule bool + Metageneration int64 + TimeCreated time.Time +} + +// AuditBucketPort is the narrow provider surface the handoff needs. It has no +// delete, list, or IAM method. A conforming implementation performs exactly +// the named mutation and nothing else. LockRetention must re-observe the +// bucket immediately before the irreversible call and refuse to lock when the +// metageneration differs from expectedMetageneration (gcloud offers no +// server-side precondition for this update). +type AuditBucketPort interface { + DescribeBucket(ctx context.Context, name string) (BucketState, bool, error) + CreateAuditBucket(ctx context.Context, identity BucketIdentity) error + ConfigureArchiveLifecycle(ctx context.Context, identity BucketIdentity) error + UploadCreateOnly(ctx context.Context, identity BucketIdentity, object AuditObjectName, content []byte) (ObjectDescriptor, error) + DescribeObject(ctx context.Context, identity BucketIdentity, object AuditObjectName) (ObjectDescriptor, bool, error) + ConfigureRetention(ctx context.Context, identity BucketIdentity, seconds int64) error + ReadObject(ctx context.Context, identity BucketIdentity, object AuditObjectName) ([]byte, ObjectDescriptor, bool, error) + LockRetention(ctx context.Context, identity BucketIdentity, expectedMetageneration int64) error +} + +// HandoffRecordV1 is one append-only local progress record. +type HandoffRecordV1 struct { + Schema string `json:"schema"` + OperationID string `json:"operationId"` + EnvelopeSHA256 string `json:"envelopeSha256"` + Sequence uint64 `json:"sequence"` + Phase HandoffPhase `json:"phase"` + RecordedAt time.Time `json:"recordedAt"` + Bucket BucketIdentity `json:"bucket"` + Envelope *ObjectDescriptor `json:"envelope,omitempty"` + Journal *ObjectDescriptor `json:"journal,omitempty"` + RecordSHA256 string `json:"recordSha256"` +} + +// HandoffStatus is the resumable view of an operation's D-158 progress. +type HandoffStatus struct { + Phase HandoffPhase + EnvelopeSHA256 string + Envelope ObjectDescriptor + Journal ObjectDescriptor + RetentionLocked bool +} + +// AuditHandoff drives K1a/K1b deterministically from the local envelope, the +// local record chain, and fresh bucket observations. +type AuditHandoff struct { + directory StateDirectory + port AuditBucketPort + clock func() time.Time +} + +// NewAuditHandoff binds one validated state directory and one port. +func NewAuditHandoff(directory StateDirectory, port AuditBucketPort, clock func() time.Time) (*AuditHandoff, error) { + if directory.path == "" || port == nil || clock == nil { + return nil, fmt.Errorf("%w: handoff construction", ErrInvalidStoreRequest) + } + return &AuditHandoff{directory: directory, port: port, clock: clock}, nil +} + +// Bootstrap performs or resumes K1a: envelope, audit bucket, create-only +// uploads, lifecycle, and verification. It never locks retention. +func (handoff *AuditHandoff) Bootstrap(ctx context.Context, envelope BootstrapEnvelopeV1) (HandoffStatus, error) { + session, err := handoff.open(ctx, envelope) + if err != nil { + return HandoffStatus{}, err + } + if session.reached(PhaseHandoffVerified) { + if _, err := session.verifyObservation(ctx); err != nil { + return HandoffStatus{}, err + } + return session.status(), nil + } + if err := session.ensureBucket(ctx); err != nil { + return HandoffStatus{}, err + } + if err := session.ensureUploads(ctx); err != nil { + return HandoffStatus{}, err + } + if err := session.ensureLifecycle(ctx); err != nil { + return HandoffStatus{}, err + } + if err := session.verify(ctx); err != nil { + return HandoffStatus{}, err + } + return session.status(), nil +} + +// LockRetention performs or resumes K1b. It re-verifies the envelope and +// journal hashes and generations before the irreversible lock and records the +// PONR only when the lock mutation is observed. +func (handoff *AuditHandoff) LockRetention(ctx context.Context, envelope BootstrapEnvelopeV1) (HandoffStatus, error) { + session, err := handoff.open(ctx, envelope) + if err != nil { + return HandoffStatus{}, err + } + if !session.reached(PhaseHandoffVerified) { + return HandoffStatus{}, fmt.Errorf("%w: handoff is not verified", ErrHandoffOutOfOrder) + } + state, err := session.verifyObservation(ctx) + if err != nil { + return HandoffStatus{}, err + } + if state.RetentionLocked { + if state.RetentionSeconds != AuditRetentionSeconds { + return HandoffStatus{}, fmt.Errorf("%w: foreign retention period on the locked audit bucket", ErrPartialBootstrapBlocked) + } + if !session.reached(PhaseRetentionLockClaimed) { + return HandoffStatus{}, fmt.Errorf("%w: retention is locked without a local lock claim", ErrPartialBootstrapBlocked) + } + if !session.reached(PhaseRetentionLocked) { + if err := session.append(PhaseRetentionLocked, nil, nil); err != nil { + return HandoffStatus{}, err + } + } + return session.status(), nil + } + if state.RetentionSeconds != AuditRetentionSeconds { + if state.RetentionSeconds != 0 { + return HandoffStatus{}, fmt.Errorf("%w: foreign retention policy", ErrPartialBootstrapBlocked) + } + if err := session.authorizeMutation(); err != nil { + return HandoffStatus{}, err + } + if err := handoff.port.ConfigureRetention(ctx, session.identity, AuditRetentionSeconds); err != nil { + return HandoffStatus{}, err + } + state, err = session.verifyObservation(ctx) + if err != nil { + return HandoffStatus{}, err + } + if state.RetentionSeconds != AuditRetentionSeconds || state.RetentionLocked { + return HandoffStatus{}, fmt.Errorf("%w: retention did not converge", ErrInvalidHandoffPort) + } + } + if !session.reached(PhaseRetentionConfigured) { + if err := session.append(PhaseRetentionConfigured, nil, nil); err != nil { + return HandoffStatus{}, err + } + } + if !session.reached(PhaseRetentionLockClaimed) { + if err := session.append(PhaseRetentionLockClaimed, nil, nil); err != nil { + return HandoffStatus{}, err + } + } + if err := session.authorizeMutation(); err != nil { + return HandoffStatus{}, err + } + if err := handoff.port.LockRetention(ctx, session.identity, state.Metageneration); err != nil { + return HandoffStatus{}, err + } + state, err = session.verifyObservation(ctx) + if err != nil { + return HandoffStatus{}, err + } + if !state.RetentionLocked || state.RetentionSeconds != AuditRetentionSeconds { + return HandoffStatus{}, fmt.Errorf("%w: lock was not observed", ErrInvalidHandoffPort) + } + if err := session.append(PhaseRetentionLocked, nil, nil); err != nil { + return HandoffStatus{}, err + } + return session.status(), nil +} + +// Status reads the local record chain without touching the provider. +func (handoff *AuditHandoff) Status(envelope BootstrapEnvelopeV1) (HandoffStatus, error) { + session, err := handoff.load(envelope) + if err != nil { + return HandoffStatus{}, err + } + return session.status(), nil +} + +type handoffSession struct { + handoff *AuditHandoff + envelope BootstrapEnvelopeV1 + identity BucketIdentity + directory string + records []HandoffRecordV1 + envelopeJSON []byte + envelopeName AuditObjectName + journalJSON []byte + journalName AuditObjectName +} + +func (handoff *AuditHandoff) open(ctx context.Context, envelope BootstrapEnvelopeV1) (*handoffSession, error) { + if err := storeContext(ctx); err != nil { + return nil, err + } + if err := envelope.validAt(handoff.clock().UTC()); err != nil { + return nil, err + } + stored, err := EnsureBootstrapEnvelope(handoff.directory, envelope) + if err != nil { + return nil, err + } + session, err := handoff.load(stored) + if err != nil { + return nil, err + } + if !session.reached(PhaseEnvelopeSealed) { + if err := session.append(PhaseEnvelopeSealed, nil, nil); err != nil { + return nil, err + } + } + return session, nil +} + +func (handoff *AuditHandoff) load(envelope BootstrapEnvelopeV1) (*handoffSession, error) { + envelopeJSON, err := envelope.CanonicalJSON() + if err != nil { + return nil, err + } + envelopeName, err := BootstrapEnvelopeObjectName(envelope.Environment(), envelope.OperationID()) + if err != nil { + return nil, err + } + directory, err := handoff.directory.handoffPath(envelope.OperationID()) + if err != nil { + return nil, err + } + session := &handoffSession{ + handoff: handoff, envelope: envelope, directory: directory, + identity: BucketIdentity{Name: envelope.AuditBucket(), Project: envelope.Project(), Location: envelope.AuditBucketLocation()}, + envelopeJSON: envelopeJSON, envelopeName: envelopeName, + journalJSON: envelope.FirstJournalEntryJSON(), journalName: envelope.FirstJournalObjectName(), + } + records, err := readHandoffRecords(directory, envelope) + if err != nil { + return nil, err + } + session.records = records + return session, nil +} + +func (session *handoffSession) reached(phase HandoffPhase) bool { + for _, record := range session.records { + if record.Phase == phase { + return true + } + } + return false +} + +func (session *handoffSession) latest() HandoffPhase { + if len(session.records) == 0 { + return "" + } + return session.records[len(session.records)-1].Phase +} + +func (session *handoffSession) recorded(phase HandoffPhase) (HandoffRecordV1, bool) { + for _, record := range session.records { + if record.Phase == phase { + return record, true + } + } + return HandoffRecordV1{}, false +} + +func (session *handoffSession) status() HandoffStatus { + status := HandoffStatus{Phase: session.latest(), EnvelopeSHA256: session.envelope.SHA256(), RetentionLocked: session.reached(PhaseRetentionLocked)} + if record, ok := session.recorded(PhaseEnvelopeUploaded); ok && record.Envelope != nil { + status.Envelope = *record.Envelope + } + if record, ok := session.recorded(PhaseJournalUploaded); ok && record.Journal != nil { + status.Journal = *record.Journal + } + return status +} + +// authorizeMutation samples the clock at the last in-process boundary before +// a provider write. Entry-time validation is insufficient because a preceding +// observation, local record, or provider call can consume the approval window. +func (session *handoffSession) authorizeMutation() error { + now := session.handoff.clock().UTC() + if len(session.records) > 0 && now.Before(session.records[len(session.records)-1].RecordedAt) { + return fmt.Errorf("%w: clock moved backwards before provider mutation", ErrInvalidHandoffRecord) + } + return session.envelope.validAt(now) +} + +func (session *handoffSession) append(phase HandoffPhase, envelopeObject, journalObject *ObjectDescriptor) error { + if phase.order() <= session.latest().order() && session.latest() != "" { + return fmt.Errorf("%w: phase %s does not advance %s", ErrInvalidHandoffRecord, phase, session.latest()) + } + now := session.handoff.clock().UTC() + if !validUTC(now) || (len(session.records) > 0 && now.Before(session.records[len(session.records)-1].RecordedAt)) { + return fmt.Errorf("%w: clock moved backwards; refusing to persist a non-monotonic record", ErrInvalidHandoffRecord) + } + record := HandoffRecordV1{ + Schema: HandoffRecordSchemaV1, OperationID: session.envelope.OperationID(), EnvelopeSHA256: session.envelope.SHA256(), + Sequence: uint64(len(session.records) + 1), Phase: phase, RecordedAt: now, Bucket: session.identity, + Envelope: cloneDescriptor(envelopeObject), Journal: cloneDescriptor(journalObject), + } + digest, err := hashJSON(record) + if err != nil { + return fmt.Errorf("%w: encoding", ErrInvalidHandoffRecord) + } + record.RecordSHA256 = digest + encoded, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("%w: encoding", ErrInvalidHandoffRecord) + } + if err := ensurePrivateDirectory(session.directory); err != nil { + return err + } + fileName := fmt.Sprintf("%020d-%s.json", record.Sequence, phase) + if err := writePrivateFileExclusive(filepath.Join(session.directory, fileName), encoded); err != nil { + return err + } + session.records = append(session.records, record) + return nil +} + +func (session *handoffSession) ensureBucket(ctx context.Context) error { + port := session.handoff.port + state, exists, err := port.DescribeBucket(ctx, session.identity.Name) + if err != nil { + return err + } + if exists { + return session.adoptExistingBucket(ctx, state) + } + if session.reached(PhaseAuditBucketCreated) { + return fmt.Errorf("%w: recorded audit bucket is absent", ErrPartialBootstrapBlocked) + } + if !session.reached(PhaseAuditBucketClaimed) { + if err := session.append(PhaseAuditBucketClaimed, nil, nil); err != nil { + return err + } + } + if err := session.authorizeMutation(); err != nil { + return err + } + if err := port.CreateAuditBucket(ctx, session.identity); err != nil { + return err + } + state, exists, err = port.DescribeBucket(ctx, session.identity.Name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%w: created bucket is not observable", ErrInvalidHandoffPort) + } + if err := session.checkPreLockState(state); err != nil { + return err + } + return session.append(PhaseAuditBucketCreated, nil, nil) +} + +func (session *handoffSession) adoptExistingBucket(ctx context.Context, state BucketState) error { + if err := session.checkPreLockState(state); err != nil { + return err + } + if session.reached(PhaseAuditBucketCreated) { + return nil + } + // Only an operation-bound remote marker proves ownership: the bucket must + // already hold exactly this envelope. A local create claim alone, or a + // creation timestamp, never adopts a bucket (D-158: an unrecorded partial + // bucket blocks for explicit recovery). + envelopeMatches, err := session.remoteEnvelopeMatches(ctx) + if err != nil { + return err + } + if !envelopeMatches { + return fmt.Errorf("%w: existing audit bucket has no matching envelope record", ErrPartialBootstrapBlocked) + } + return session.append(PhaseAuditBucketCreated, nil, nil) +} + +// remoteEnvelopeMatches reports whether the bucket already holds exactly this +// envelope, compared byte for byte. A present object with different content +// is a conflict. +func (session *handoffSession) remoteEnvelopeMatches(ctx context.Context) (bool, error) { + return session.remoteObjectMatches(ctx, session.envelopeName, session.envelopeJSON) +} + +func (session *handoffSession) remoteObjectMatches(ctx context.Context, name AuditObjectName, content []byte) (bool, error) { + remote, descriptor, exists, err := session.handoff.port.ReadObject(ctx, session.identity, name) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + if !descriptor.MatchesContent(content) || !bytes.Equal(remote, content) { + return false, fmt.Errorf("%w: audit bucket holds different content at %s", ErrPartialBootstrapBlocked, name) + } + return true, nil +} + +func (session *handoffSession) ensureUploads(ctx context.Context) error { + if !session.reached(PhaseEnvelopeUploaded) { + descriptor, err := session.uploadCreateOnly(ctx, session.envelopeName, session.envelopeJSON) + if err != nil { + return err + } + if err := session.append(PhaseEnvelopeUploaded, &descriptor, nil); err != nil { + return err + } + } + if !session.reached(PhaseJournalUploaded) { + descriptor, err := session.uploadCreateOnly(ctx, session.journalName, session.journalJSON) + if err != nil { + return err + } + if err := session.append(PhaseJournalUploaded, nil, &descriptor); err != nil { + return err + } + } + return nil +} + +func (session *handoffSession) uploadCreateOnly(ctx context.Context, name AuditObjectName, content []byte) (ObjectDescriptor, error) { + port := session.handoff.port + if err := session.authorizeMutation(); err != nil { + return ObjectDescriptor{}, err + } + descriptor, err := port.UploadCreateOnly(ctx, session.identity, name, content) + if err == nil { + if !descriptor.MatchesContent(content) { + return ObjectDescriptor{}, fmt.Errorf("%w: upload descriptor mismatch", ErrInvalidHandoffPort) + } + return descriptor, nil + } + if !errors.Is(err, ErrPreconditionFailed) { + return ObjectDescriptor{}, err + } + matches, matchErr := session.remoteObjectMatches(ctx, name, content) + if matchErr != nil { + return ObjectDescriptor{}, matchErr + } + if !matches { + return ObjectDescriptor{}, fmt.Errorf("%w: %s create precondition failed but the object is not observable", ErrPartialBootstrapBlocked, name) + } + existing, exists, describeErr := port.DescribeObject(ctx, session.identity, name) + if describeErr != nil { + return ObjectDescriptor{}, describeErr + } + if !exists || !existing.MatchesContent(content) { + return ObjectDescriptor{}, fmt.Errorf("%w: %s holds conflicting content", ErrPartialBootstrapBlocked, name) + } + return existing, nil +} + +func (session *handoffSession) ensureLifecycle(ctx context.Context) error { + if session.reached(PhaseLifecycleConfigured) { + return nil + } + state, exists, err := session.handoff.port.DescribeBucket(ctx, session.identity.Name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%w: audit bucket vanished", ErrPartialBootstrapBlocked) + } + if err := session.checkPreLockState(state); err != nil { + return err + } + if state.LifecycleArchiveAfterDay != AuditArchiveAfterDays { + if err := session.authorizeMutation(); err != nil { + return err + } + if err := session.handoff.port.ConfigureArchiveLifecycle(ctx, session.identity); err != nil { + return err + } + } + return session.append(PhaseLifecycleConfigured, nil, nil) +} + +func (session *handoffSession) verify(ctx context.Context) error { + if _, err := session.verifyObservation(ctx); err != nil { + return err + } + return session.append(PhaseHandoffVerified, nil, nil) +} + +// verifyObservation re-observes the bucket and both objects and requires exact +// hash and generation equality with the recorded uploads. +func (session *handoffSession) verifyObservation(ctx context.Context) (BucketState, error) { + port := session.handoff.port + state, exists, err := port.DescribeBucket(ctx, session.identity.Name) + if err != nil { + return BucketState{}, err + } + if !exists { + return BucketState{}, fmt.Errorf("%w: audit bucket vanished", ErrPartialBootstrapBlocked) + } + if err := session.checkCommonState(state); err != nil { + return BucketState{}, err + } + if state.LifecycleArchiveAfterDay != AuditArchiveAfterDays { + return BucketState{}, fmt.Errorf("%w: archive lifecycle is not configured", ErrPartialBootstrapBlocked) + } + envelopeRecord, ok := session.recorded(PhaseEnvelopeUploaded) + if !ok || envelopeRecord.Envelope == nil { + return BucketState{}, fmt.Errorf("%w: envelope upload is unrecorded", ErrHandoffOutOfOrder) + } + journalRecord, ok := session.recorded(PhaseJournalUploaded) + if !ok || journalRecord.Journal == nil { + return BucketState{}, fmt.Errorf("%w: journal upload is unrecorded", ErrHandoffOutOfOrder) + } + if err := session.verifyObject(ctx, session.envelopeName, session.envelopeJSON, *envelopeRecord.Envelope); err != nil { + return BucketState{}, err + } + if err := session.verifyObject(ctx, session.journalName, session.journalJSON, *journalRecord.Journal); err != nil { + return BucketState{}, err + } + return state, nil +} + +func (session *handoffSession) verifyObject(ctx context.Context, name AuditObjectName, content []byte, recorded ObjectDescriptor) error { + observed, exists, err := session.handoff.port.DescribeObject(ctx, session.identity, name) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%w: %s is absent", ErrPartialBootstrapBlocked, name) + } + if observed != recorded || !observed.MatchesContent(content) { + return fmt.Errorf("%w: %s hash or generation differs from the recorded upload", ErrPartialBootstrapBlocked, name) + } + remote, remoteDescriptor, remoteExists, err := session.handoff.port.ReadObject(ctx, session.identity, name) + if err != nil { + return err + } + if !remoteExists || remoteDescriptor != recorded || !bytes.Equal(remote, content) { + return fmt.Errorf("%w: %s bytes differ from the recorded upload", ErrPartialBootstrapBlocked, name) + } + return nil +} + +func (session *handoffSession) checkPreLockState(state BucketState) error { + if err := session.checkCommonState(state); err != nil { + return err + } + if state.RetentionLocked { + return fmt.Errorf("%w: retention is already locked before the handoff was verified", ErrPartialBootstrapBlocked) + } + if state.RetentionSeconds != 0 && state.RetentionSeconds != AuditRetentionSeconds { + return fmt.Errorf("%w: foreign retention policy", ErrPartialBootstrapBlocked) + } + return nil +} + +func (session *handoffSession) checkCommonState(state BucketState) error { + if state.Identity != session.identity { + return fmt.Errorf("%w: bucket identity differs from the approved audit bucket", ErrPartialBootstrapBlocked) + } + if !state.UniformBucketLevelAccess || state.PublicAccessPrevention != PublicAccessPreventionEnforced || + state.StorageClass != AuditStorageClass || !state.Versioning || state.LifecycleDeleteRule || + (state.LifecycleArchiveAfterDay != 0 && state.LifecycleArchiveAfterDay != AuditArchiveAfterDays) { + return fmt.Errorf("%w: bucket state is not the exact compliant audit configuration", ErrPartialBootstrapBlocked) + } + return nil +} + +func readHandoffRecords(directory string, envelope BootstrapEnvelopeV1) ([]HandoffRecordV1, error) { + names, err := listStateFiles(directory) + if err != nil { + return nil, err + } + records := make([]HandoffRecordV1, 0, len(names)) + previous := phaseOrderUnknown + for index, name := range names { + encoded, err := readPrivateFile(filepath.Join(directory, name), maxHandoffRecordBytes) + if err != nil { + return nil, err + } + record, err := parseHandoffRecord(encoded) + if err != nil { + return nil, err + } + if record.Sequence != uint64(index+1) || record.OperationID != envelope.OperationID() || + record.EnvelopeSHA256 != envelope.SHA256() || record.Phase.order() <= previous || + name != fmt.Sprintf("%020d-%s.json", record.Sequence, record.Phase) || + record.Bucket != (BucketIdentity{Name: envelope.AuditBucket(), Project: envelope.Project(), Location: envelope.AuditBucketLocation()}) { + return nil, fmt.Errorf("%w: chain is not contiguous, ordered, and bound to the envelope", ErrInvalidHandoffRecord) + } + if index > 0 && record.RecordedAt.Before(records[index-1].RecordedAt) { + return nil, fmt.Errorf("%w: recordedAt moved backwards", ErrInvalidHandoffRecord) + } + previous = record.Phase.order() + records = append(records, record) + } + return records, nil +} + +func parseHandoffRecord(encoded []byte) (HandoffRecordV1, error) { + if err := rejectDuplicateKeys(encoded); err != nil { + return HandoffRecordV1{}, fmt.Errorf("%w: %v", ErrInvalidHandoffRecord, err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + var record HandoffRecordV1 + if err := decoder.Decode(&record); err != nil { + return HandoffRecordV1{}, fmt.Errorf("%w: schema", ErrInvalidHandoffRecord) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return HandoffRecordV1{}, fmt.Errorf("%w: trailing data", ErrInvalidHandoffRecord) + } + if record.Schema != HandoffRecordSchemaV1 || record.Phase.order() == phaseOrderUnknown || !validUTC(record.RecordedAt) || + !sha256Pattern.MatchString(record.RecordSHA256) || !sha256Pattern.MatchString(record.EnvelopeSHA256) || + !operationIDPattern.MatchString(record.OperationID) || record.Sequence == 0 || + (record.Phase == PhaseEnvelopeUploaded) != (record.Envelope != nil) || + (record.Phase == PhaseJournalUploaded) != (record.Journal != nil) || + (record.Envelope != nil && record.Envelope.Generation == 0) || (record.Journal != nil && record.Journal.Generation == 0) { + return HandoffRecordV1{}, fmt.Errorf("%w: fields", ErrInvalidHandoffRecord) + } + copy := record + copy.RecordSHA256 = "" + digest, err := hashJSON(copy) + if err != nil || digest != record.RecordSHA256 { + return HandoffRecordV1{}, fmt.Errorf("%w: integrity", ErrInvalidHandoffRecord) + } + canonical, err := json.Marshal(record) + if err != nil || !bytes.Equal(canonical, encoded) { + return HandoffRecordV1{}, fmt.Errorf("%w: noncanonical", ErrInvalidHandoffRecord) + } + return record, nil +} + +func cloneDescriptor(value *ObjectDescriptor) *ObjectDescriptor { + if value == nil { + return nil + } + copy := *value + return © +} + +// String renders a phase for diagnostics without provider values. +func (phase HandoffPhase) String() string { return strings.TrimSpace(string(phase)) } diff --git a/internal/control/handoff_test.go b/internal/control/handoff_test.go new file mode 100644 index 0000000..60c7db3 --- /dev/null +++ b/internal/control/handoff_test.go @@ -0,0 +1,756 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +var errSimulatedCrash = errors.New("simulated crash after provider mutation") + +// fakeAuditBucket is an in-memory provider with exact typed state. A crash +// hook fires after the named mutation has already been applied, modelling a +// process death between the provider call and the local record. +type fakeAuditBucket struct { + mutex sync.Mutex + clock func() time.Time + globalTaken map[string]bool + buckets map[string]*BucketState + objects map[string]map[string]fakeObject + generation Generation + crashAfter map[string]bool + calls map[string]int + afterMutation func(string) +} + +type fakeObject struct { + descriptor ObjectDescriptor + content []byte +} + +func newFakeAuditBucket(clock func() time.Time) *fakeAuditBucket { + return &fakeAuditBucket{clock: clock, globalTaken: map[string]bool{}, buckets: map[string]*BucketState{}, + objects: map[string]map[string]fakeObject{}, crashAfter: map[string]bool{}, calls: map[string]int{}} +} + +func (fake *fakeAuditBucket) count(name string) int { + fake.mutex.Lock() + defer fake.mutex.Unlock() + return fake.calls[name] +} + +func (fake *fakeAuditBucket) after(name string) error { + fake.calls[name]++ + if fake.afterMutation != nil { + fake.afterMutation(name) + } + if fake.crashAfter[name] { + delete(fake.crashAfter, name) + return errSimulatedCrash + } + return nil +} + +func (fake *fakeAuditBucket) DescribeBucket(_ context.Context, name string) (BucketState, bool, error) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + fake.calls["describe-bucket"]++ + state, exists := fake.buckets[name] + if !exists { + return BucketState{}, false, nil + } + return *state, true, nil +} + +func (fake *fakeAuditBucket) CreateAuditBucket(_ context.Context, identity BucketIdentity) error { + fake.mutex.Lock() + defer fake.mutex.Unlock() + if fake.globalTaken[identity.Name] || fake.buckets[identity.Name] != nil { + fake.calls["create-conflict"]++ + return ErrBucketNameConflict + } + fake.buckets[identity.Name] = &BucketState{Identity: identity, UniformBucketLevelAccess: true, + PublicAccessPrevention: PublicAccessPreventionEnforced, StorageClass: AuditStorageClass, Versioning: true, + Metageneration: 1, TimeCreated: fake.clock().UTC()} + fake.objects[identity.Name] = map[string]fakeObject{} + return fake.after("create-bucket") +} + +func (fake *fakeAuditBucket) ConfigureArchiveLifecycle(_ context.Context, identity BucketIdentity) error { + fake.mutex.Lock() + defer fake.mutex.Unlock() + bucket := fake.buckets[identity.Name] + if bucket == nil { + return errors.New("fake: lifecycle on absent bucket") + } + bucket.LifecycleArchiveAfterDay = AuditArchiveAfterDays + bucket.Metageneration++ + return fake.after("lifecycle") +} + +func (fake *fakeAuditBucket) UploadCreateOnly(_ context.Context, identity BucketIdentity, object AuditObjectName, content []byte) (ObjectDescriptor, error) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + objects := fake.objects[identity.Name] + if objects == nil { + return ObjectDescriptor{}, errors.New("fake: upload to absent bucket") + } + if _, exists := objects[object.String()]; exists { + return ObjectDescriptor{}, fmt.Errorf("%w: %s", ErrPreconditionFailed, object) + } + fake.generation++ + descriptor := DescribeContent(content) + descriptor.Generation = fake.generation + objects[object.String()] = fakeObject{descriptor: descriptor, content: append([]byte(nil), content...)} + return descriptor, fake.after("upload:" + object.String()) +} + +func (fake *fakeAuditBucket) DescribeObject(_ context.Context, identity BucketIdentity, object AuditObjectName) (ObjectDescriptor, bool, error) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + fake.calls["describe-object"]++ + stored, exists := fake.objects[identity.Name][object.String()] + if !exists { + return ObjectDescriptor{}, false, nil + } + return stored.descriptor, true, nil +} + +func (fake *fakeAuditBucket) ConfigureRetention(_ context.Context, identity BucketIdentity, seconds int64) error { + fake.mutex.Lock() + defer fake.mutex.Unlock() + bucket := fake.buckets[identity.Name] + if bucket == nil || bucket.RetentionLocked { + return errors.New("fake: retention change refused") + } + bucket.RetentionSeconds = seconds + bucket.Metageneration++ + return fake.after("retention") +} + +func (fake *fakeAuditBucket) ReadObject(_ context.Context, identity BucketIdentity, object AuditObjectName) ([]byte, ObjectDescriptor, bool, error) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + fake.calls["read-object"]++ + stored, exists := fake.objects[identity.Name][object.String()] + if !exists { + return nil, ObjectDescriptor{}, false, nil + } + return append([]byte(nil), stored.content...), stored.descriptor, true, nil +} + +func (fake *fakeAuditBucket) LockRetention(_ context.Context, identity BucketIdentity, expectedMetageneration int64) error { + fake.mutex.Lock() + defer fake.mutex.Unlock() + bucket := fake.buckets[identity.Name] + if bucket == nil || bucket.RetentionSeconds == 0 { + return errors.New("fake: lock without retention") + } + if bucket.Metageneration != expectedMetageneration { + fake.calls["lock-precondition-failed"]++ + return fmt.Errorf("%w: bucket metageneration moved before the lock", ErrPreconditionFailed) + } + bucket.RetentionLocked = true + bucket.Metageneration++ + return fake.after("lock") +} + +func (fake *fakeAuditBucket) seedForeignObject(bucket, name string, content []byte) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + fake.generation++ + descriptor := DescribeContent(content) + descriptor.Generation = fake.generation + fake.objects[bucket][name] = fakeObject{descriptor: descriptor, content: content} +} + +func (fake *fakeAuditBucket) seedBucket(identity BucketIdentity, mutate func(*BucketState)) { + fake.mutex.Lock() + defer fake.mutex.Unlock() + state := &BucketState{Identity: identity, UniformBucketLevelAccess: true, PublicAccessPrevention: PublicAccessPreventionEnforced, + StorageClass: AuditStorageClass, Versioning: true, Metageneration: 3, TimeCreated: fixtureNow.Add(-24 * time.Hour)} + if mutate != nil { + mutate(state) + } + fake.buckets[identity.Name] = state + fake.objects[identity.Name] = map[string]fakeObject{} +} + +type handoffFixture struct { + envelope BootstrapEnvelopeV1 + directory StateDirectory + fake *fakeAuditBucket + handoff *AuditHandoff + identity BucketIdentity +} + +func newHandoffFixture(t *testing.T) *handoffFixture { + t.Helper() + envelope := fixtureEnvelope(t) + directory := fixtureStateDirectory(t) + current := fixtureNow.Add(3 * time.Minute) + clock := func() time.Time { current = current.Add(time.Second); return current } + fake := newFakeAuditBucket(clock) + handoff, err := NewAuditHandoff(directory, fake, clock) + if err != nil { + t.Fatalf("NewAuditHandoff() error: %v", err) + } + return &handoffFixture{envelope: envelope, directory: directory, fake: fake, handoff: handoff, + identity: BucketIdentity{Name: envelope.AuditBucket(), Project: envelope.Project(), Location: envelope.AuditBucketLocation()}} +} + +func (fixture *handoffFixture) phases(t *testing.T) []HandoffPhase { + t.Helper() + directory, _ := fixture.directory.handoffPath(fixtureOperationID) + records, err := readHandoffRecords(directory, fixture.envelope) + if err != nil { + t.Fatalf("readHandoffRecords() error: %v", err) + } + result := make([]HandoffPhase, len(records)) + for index, record := range records { + result[index] = record.Phase + } + return result +} + +func (fixture *handoffFixture) assertComplete(t *testing.T) { + t.Helper() + ctx := context.Background() + status, err := fixture.handoff.Bootstrap(ctx, fixture.envelope) + if err != nil || status.Phase.order() < PhaseHandoffVerified.order() { + t.Fatalf("Bootstrap() = %#v, %v", status, err) + } + locked, err := fixture.handoff.LockRetention(ctx, fixture.envelope) + if err != nil || locked.Phase != PhaseRetentionLocked || !locked.RetentionLocked { + t.Fatalf("LockRetention() = %#v, %v", locked, err) + } + fake := fixture.fake + if fake.count("create-bucket") != 1 || fake.count("lock") != 1 || fake.count("retention") != 1 || fake.count("lifecycle") != 1 || + fake.count("create-conflict") != 0 || fake.count("lock-precondition-failed") != 0 { + t.Fatalf("mutation counts = %v", fake.calls) + } + envelopeName, _ := BootstrapEnvelopeObjectName(fixture.envelope.Environment(), fixtureOperationID) + if fake.count("upload:"+envelopeName.String()) != 1 || fake.count("upload:"+fixture.envelope.FirstJournalObjectName().String()) != 1 { + t.Fatalf("upload counts = %v", fake.calls) + } + state, _, _ := fake.DescribeBucket(ctx, fixture.identity.Name) + if !state.RetentionLocked || state.RetentionSeconds != AuditRetentionSeconds || state.LifecycleArchiveAfterDay != AuditArchiveAfterDays || state.LifecycleDeleteRule { + t.Fatalf("final bucket state = %#v", state) + } + want := []HandoffPhase{PhaseEnvelopeSealed, PhaseAuditBucketClaimed, PhaseAuditBucketCreated, PhaseEnvelopeUploaded, PhaseJournalUploaded, + PhaseLifecycleConfigured, PhaseHandoffVerified, PhaseRetentionConfigured, PhaseRetentionLockClaimed, PhaseRetentionLocked} + got := fixture.phases(t) + if len(got) != len(want) { + t.Fatalf("phases = %v", got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("phases = %v", got) + } + } + // Re-running both steps is idempotent and performs no further mutation. + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatalf("idempotent Bootstrap() error: %v", err) + } + if again, err := fixture.handoff.LockRetention(ctx, fixture.envelope); err != nil || again.Phase != PhaseRetentionLocked { + t.Fatalf("idempotent LockRetention() = %#v, %v", again, err) + } + if fake.count("create-bucket") != 1 || fake.count("lock") != 1 || fake.count("retention") != 1 { + t.Fatalf("idempotent rerun mutated: %v", fake.calls) + } +} + +func TestAuditHandoffCompletesAndIsIdempotent(t *testing.T) { + t.Parallel() + newHandoffFixture(t).assertComplete(t) +} + +func TestAuditHandoffResumesAfterCrashAtEveryBoundary(t *testing.T) { + t.Parallel() + envelopeName, _ := BootstrapEnvelopeObjectName("disposable-test", fixtureOperationID) + journalName := fixtureEnvelope(t).FirstJournalObjectName().String() + boundaries := []string{"create-bucket", "upload:" + envelopeName.String(), "upload:" + journalName, "lifecycle", "retention", "lock"} + for _, boundary := range boundaries { + t.Run(boundary, func(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + fixture.fake.crashAfter[boundary] = true + ctx := context.Background() + _, bootstrapErr := fixture.handoff.Bootstrap(ctx, fixture.envelope) + if boundary == "retention" || boundary == "lock" { + if bootstrapErr != nil { + t.Fatalf("Bootstrap() error: %v", bootstrapErr) + } + if _, err := fixture.handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, errSimulatedCrash) { + t.Fatalf("LockRetention() error = %v, want the simulated crash", err) + } + } else if !errors.Is(bootstrapErr, errSimulatedCrash) { + t.Fatalf("Bootstrap() error = %v, want the simulated crash", bootstrapErr) + } + // A fresh process with the same state directory and provider resumes. + resumed, err := NewAuditHandoff(fixture.directory, fixture.fake, fixture.handoff.clock) + if err != nil { + t.Fatal(err) + } + fixture.handoff = resumed + if boundary == "create-bucket" { + // The bucket exists but holds no envelope: only an operation-bound + // remote marker adopts a bucket, so this blocks for explicit + // recovery without any further mutation (D-158). + _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope) + if !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("resume after create crash error = %v, want ErrPartialBootstrapBlocked", err) + } + if fixture.fake.count("create-bucket") != 1 || fixture.fake.count("lifecycle") != 0 || fixture.fake.count("lock") != 0 { + t.Fatalf("blocked resume mutated: %v", fixture.fake.calls) + } + return + } + fixture.assertComplete(t) + }) + } +} + +func TestAuditHandoffResumesBeforeAndAfterEnvelope(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + // Crash before the envelope: nothing exists yet; a fresh run proceeds. + if _, err := os.Stat(filepath.Join(fixture.directory.Path(), "bootstrap-envelope-"+fixtureOperationID+".json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("envelope exists early: %v", err) + } + // Crash after the envelope only: the file exists, no records, no bucket. + if err := WriteBootstrapEnvelope(fixture.directory, fixture.envelope); err != nil { + t.Fatal(err) + } + fixture.assertComplete(t) +} + +func TestAuditHandoffRetryAcceptsOnlyTheSameEnvelopeHash(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + ctx := context.Background() + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + seed := fixtureSeed(t) + seed.SealedAt = seed.SealedAt.Add(time.Second) + different, err := SealBootstrapEnvelope(seed) + if err != nil { + t.Fatal(err) + } + if _, err := fixture.handoff.Bootstrap(ctx, different); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("Bootstrap(different envelope) error = %v", err) + } + if _, err := fixture.handoff.LockRetention(ctx, different); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("LockRetention(different envelope) error = %v", err) + } + if fixture.fake.count("lock") != 0 { + t.Fatal("a conflicting envelope reached the retention lock") + } +} + +func TestAuditHandoffBucketNameConflictIsAnExplicitPreconditionFailure(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + fixture.fake.globalTaken[fixture.identity.Name] = true + _, err := fixture.handoff.Bootstrap(context.Background(), fixture.envelope) + if !errors.Is(err, ErrBucketNameConflict) || errors.Is(err, ErrObjectNotFound) { + t.Fatalf("Bootstrap() error = %v, want ErrBucketNameConflict", err) + } + if got := fixture.phases(t); len(got) != 2 || got[1] != PhaseAuditBucketClaimed { + t.Fatalf("phases after conflict = %v (create must never be recorded)", got) + } + if fixture.fake.count("lock") != 0 || fixture.fake.count("describe-object") != 0 || fixture.fake.count("read-object") != 0 { + t.Fatalf("calls after conflict = %v", fixture.fake.calls) + } +} + +func TestAuditHandoffBlocksUnrecordedOrConflictingPartialBuckets(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + envelopeJSON, _ := envelope.CanonicalJSON() + envelopeName, _ := BootstrapEnvelopeObjectName(envelope.Environment(), fixtureOperationID) + tests := []struct { + name string + seed func(fixture *handoffFixture) + wantE error + }{ + {"unrecorded empty bucket", func(fixture *handoffFixture) { fixture.fake.seedBucket(fixture.identity, nil) }, ErrPartialBootstrapBlocked}, + {"bucket with a different envelope", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, nil) + fixture.fake.seedForeignObject(fixture.identity.Name, envelopeName.String(), []byte(`{"foreign":true}`)) + }, ErrPartialBootstrapBlocked}, + {"noncompliant bucket without UBLA", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { state.UniformBucketLevelAccess = false }) + }, ErrPartialBootstrapBlocked}, + {"bucket in another project", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { state.Identity.Project = "other-project" }) + }, ErrPartialBootstrapBlocked}, + {"bucket in another location", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { state.Identity.Location = "europe-west1" }) + }, ErrPartialBootstrapBlocked}, + {"bucket with a delete lifecycle rule", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { state.LifecycleDeleteRule = true }) + }, ErrPartialBootstrapBlocked}, + {"bucket already retention locked", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { + state.RetentionSeconds, state.RetentionLocked = AuditRetentionSeconds, true + }) + }, ErrPartialBootstrapBlocked}, + {"bucket with a foreign retention period", func(fixture *handoffFixture) { + fixture.fake.seedBucket(fixture.identity, func(state *BucketState) { state.RetentionSeconds = 86400 }) + }, ErrPartialBootstrapBlocked}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + test.seed(fixture) + _, err := fixture.handoff.Bootstrap(context.Background(), fixture.envelope) + if !errors.Is(err, test.wantE) { + t.Fatalf("Bootstrap() error = %v, want %v", err, test.wantE) + } + if fixture.fake.count("create-bucket") != 0 || fixture.fake.count("lock") != 0 || fixture.fake.count("retention") != 0 { + t.Fatalf("blocked bootstrap mutated: %v", fixture.fake.calls) + } + for _, objects := range fixture.fake.objects { + for name := range objects { + if name == envelopeName.String() && string(objects[name].content) == string(envelopeJSON) { + t.Fatal("blocked bootstrap uploaded the envelope") + } + } + } + }) + } +} + +func TestAuditHandoffAdoptsBucketHoldingTheIdenticalEnvelope(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + envelopeJSON, _ := fixture.envelope.CanonicalJSON() + envelopeName, _ := BootstrapEnvelopeObjectName(fixture.envelope.Environment(), fixtureOperationID) + fixture.fake.seedBucket(fixture.identity, nil) + fixture.fake.seedForeignObject(fixture.identity.Name, envelopeName.String(), envelopeJSON) + ctx := context.Background() + status, err := fixture.handoff.Bootstrap(ctx, fixture.envelope) + if err != nil || status.Phase != PhaseHandoffVerified { + t.Fatalf("Bootstrap() = %#v, %v", status, err) + } + if fixture.fake.count("create-bucket") != 0 || fixture.fake.count("upload:"+envelopeName.String()) != 0 { + t.Fatalf("adoption re-created or re-uploaded: %v", fixture.fake.calls) + } + if _, err := fixture.handoff.LockRetention(ctx, fixture.envelope); err != nil { + t.Fatalf("LockRetention() error: %v", err) + } +} + +func TestAuditHandoffLockRequiresVerifiedHandoffAndLocalClaim(t *testing.T) { + t.Parallel() + ctx := context.Background() + early := newHandoffFixture(t) + if _, err := early.handoff.LockRetention(ctx, early.envelope); !errors.Is(err, ErrHandoffOutOfOrder) { + t.Fatalf("LockRetention() before Bootstrap error = %v", err) + } + if early.fake.count("lock") != 0 || early.fake.count("retention") != 0 { + t.Fatalf("premature lock mutated: %v", early.fake.calls) + } + + drifted := newHandoffFixture(t) + if _, err := drifted.handoff.Bootstrap(ctx, drifted.envelope); err != nil { + t.Fatal(err) + } + // Someone else locked retention out of band: no local claim exists. + drifted.fake.buckets[drifted.identity.Name].RetentionSeconds = AuditRetentionSeconds + drifted.fake.buckets[drifted.identity.Name].RetentionLocked = true + if _, err := drifted.handoff.LockRetention(ctx, drifted.envelope); !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("unclaimed observed lock error = %v", err) + } + + tampered := newHandoffFixture(t) + if _, err := tampered.handoff.Bootstrap(ctx, tampered.envelope); err != nil { + t.Fatal(err) + } + envelopeName, _ := BootstrapEnvelopeObjectName(tampered.envelope.Environment(), fixtureOperationID) + stored := tampered.fake.objects[tampered.identity.Name][envelopeName.String()] + stored.descriptor.Generation++ + tampered.fake.objects[tampered.identity.Name][envelopeName.String()] = stored + if _, err := tampered.handoff.LockRetention(ctx, tampered.envelope); !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("generation drift before lock error = %v", err) + } + if tampered.fake.count("lock") != 0 { + t.Fatal("lock proceeded despite generation drift") + } +} + +func TestAuditHandoffRejectsTamperedLocalRecords(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + ctx := context.Background() + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + directory, _ := fixture.directory.handoffPath(fixtureOperationID) + names, _ := listStateFiles(directory) + if len(names) == 0 { + t.Fatal("no records") + } + target := filepath.Join(directory, names[len(names)-1]) + content, _ := os.ReadFile(target) + content[len(content)/2] ^= 0x04 + if err := os.WriteFile(target, content, 0o600); err != nil { + t.Fatal(err) + } + if _, err := fixture.handoff.Status(fixture.envelope); !errors.Is(err, ErrInvalidHandoffRecord) { + t.Fatalf("Status() with a tampered record error = %v", err) + } + if _, err := fixture.handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, ErrInvalidHandoffRecord) { + t.Fatalf("LockRetention() with a tampered record error = %v", err) + } + if fixture.fake.count("lock") != 0 { + t.Fatal("lock proceeded on a tampered chain") + } +} + +func TestAuditHandoffBlocksForeignRetentionOnLockedBucketAfterCrash(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + ctx := context.Background() + fixture.fake.crashAfter["lock"] = true + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + if _, err := fixture.handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, errSimulatedCrash) { + t.Fatalf("LockRetention() error = %v", err) + } + // A foreign actor changed the period on the locked bucket before the retry. + fixture.fake.buckets[fixture.identity.Name].RetentionSeconds = AuditRetentionSeconds + 86400 + status, err := fixture.handoff.LockRetention(ctx, fixture.envelope) + if !errors.Is(err, ErrPartialBootstrapBlocked) || status.RetentionLocked { + t.Fatalf("retry over a foreign period = %#v, %v; want ErrPartialBootstrapBlocked", status, err) + } + if fixture.phases(t)[len(fixture.phases(t))-1] == PhaseRetentionLocked { + t.Fatal("retention-locked was recorded over contradictory state") + } +} + +func TestAuditHandoffLockRefusesWhenMetagenerationMoves(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + ctx := context.Background() + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + // The retention period is configured, then the bucket changes underneath + // the lock call: the port must refuse instead of locking unapproved state. + fixture.fake.buckets[fixture.identity.Name].RetentionSeconds = AuditRetentionSeconds + fixture.fake.crashAfter["retention"] = false + original := fixture.fake.buckets[fixture.identity.Name].Metageneration + fixture.fake.calls["describe-bucket"] = 0 + racing := &racingPort{fakeAuditBucket: fixture.fake, bump: func() { + fixture.fake.buckets[fixture.identity.Name].Metageneration = original + 100 + }} + handoff, err := NewAuditHandoff(fixture.directory, racing, fixture.handoff.clock) + if err != nil { + t.Fatal(err) + } + if _, err := handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, ErrPreconditionFailed) { + t.Fatalf("LockRetention() error = %v, want ErrPreconditionFailed", err) + } + if fixture.fake.buckets[fixture.identity.Name].RetentionLocked || fixture.fake.count("lock") != 0 { + t.Fatal("lock was applied despite a moved metageneration") + } +} + +// racingPort mutates the bucket between the handoff's last observation and the +// lock call. +type racingPort struct { + *fakeAuditBucket + bump func() +} + +func (port *racingPort) LockRetention(ctx context.Context, identity BucketIdentity, expected int64) error { + port.bump() + return port.fakeAuditBucket.LockRetention(ctx, identity, expected) +} + +func TestAuditHandoffRefusesExpiredAuthorization(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + late := fixture.envelope.Approval().ValidUntil + expired, err := NewAuditHandoff(fixture.directory, fixture.fake, func() time.Time { return late }) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := expired.Bootstrap(ctx, fixture.envelope); !errors.Is(err, ErrEnvelopeExpired) { + t.Fatalf("Bootstrap() after approval expiry error = %v", err) + } + if _, err := expired.LockRetention(ctx, fixture.envelope); !errors.Is(err, ErrEnvelopeExpired) { + t.Fatalf("LockRetention() after approval expiry error = %v", err) + } + early, _ := NewAuditHandoff(fixture.directory, fixture.fake, func() time.Time { return fixtureNow }) + if _, err := early.Bootstrap(ctx, fixture.envelope); !errors.Is(err, ErrEnvelopeExpired) { + t.Fatalf("Bootstrap() before approval error = %v", err) + } + if fixture.fake.count("create-bucket") != 0 || fixture.fake.count("describe-bucket") != 0 { + t.Fatalf("expired authorization reached the provider: %v", fixture.fake.calls) + } +} + +func TestAuditHandoffRechecksAuthorizationBeforeEachProviderMutation(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("bootstrap", func(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + current := fixtureNow.Add(3 * time.Minute) + clock := func() time.Time { return current } + fake := newFakeAuditBucket(clock) + fake.afterMutation = func(name string) { + if name == "create-bucket" { + current = envelope.Approval().ValidUntil + } + } + handoff, err := NewAuditHandoff(fixtureStateDirectory(t), fake, clock) + if err != nil { + t.Fatal(err) + } + if _, err := handoff.Bootstrap(ctx, envelope); !errors.Is(err, ErrEnvelopeExpired) { + t.Fatalf("Bootstrap() after approval expired between mutations error = %v", err) + } + if fake.count("create-bucket") != 1 || fake.count("lifecycle") != 0 { + t.Fatalf("provider mutations after expiry = %v", fake.calls) + } + envelopeName, _ := BootstrapEnvelopeObjectName(envelope.Environment(), envelope.OperationID()) + if fake.count("upload:"+envelopeName.String()) != 0 || fake.count("upload:"+envelope.FirstJournalObjectName().String()) != 0 { + t.Fatalf("uploads continued after expiry: %v", fake.calls) + } + }) + + t.Run("retention lock", func(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + directory, _ := fixture.directory.handoffPath(fixture.envelope.OperationID()) + records, err := readHandoffRecords(directory, fixture.envelope) + if err != nil { + t.Fatal(err) + } + current := records[len(records)-1].RecordedAt + clock := func() time.Time { return current } + fixture.fake.clock = clock + fixture.fake.afterMutation = func(name string) { + if name == "retention" { + current = fixture.envelope.Approval().ValidUntil + } + } + handoff, err := NewAuditHandoff(fixture.directory, fixture.fake, clock) + if err != nil { + t.Fatal(err) + } + if _, err := handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, ErrEnvelopeExpired) { + t.Fatalf("LockRetention() after approval expired between mutations error = %v", err) + } + if fixture.fake.count("retention") != 1 || fixture.fake.count("lock") != 0 || fixture.fake.buckets[fixture.identity.Name].RetentionLocked { + t.Fatalf("retention lock continued after expiry: %v", fixture.fake.calls) + } + }) +} + +func TestAuditHandoffRefusesBackwardClockBeforePersisting(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + directory := fixtureStateDirectory(t) + // The first record is stamped at T+4m; every later reading regresses to + // T+3m, so the create claim must be refused before the provider is called. + index := 0 + clock := func() time.Time { + index++ + if index <= 2 { + return fixtureNow.Add(4 * time.Minute) + } + return fixtureNow.Add(3 * time.Minute) + } + fake := newFakeAuditBucket(clock) + handoff, err := NewAuditHandoff(directory, fake, clock) + if err != nil { + t.Fatal(err) + } + if _, err := handoff.Bootstrap(context.Background(), envelope); !errors.Is(err, ErrInvalidHandoffRecord) { + t.Fatalf("Bootstrap() with a regressing clock error = %v", err) + } + if fake.count("create-bucket") != 0 { + t.Fatal("a regressing clock still reached the provider") + } +} + +func TestAuditHandoffRefusesBackwardClockBeforeLaterMutation(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + clockReads := 0 + clock := func() time.Time { + clockReads++ + if clockReads >= 7 { + return fixtureNow.Add(2*time.Minute + 30*time.Second) + } + return fixtureNow.Add(3 * time.Minute) + } + fake := newFakeAuditBucket(clock) + // The clock regresses after the create is observed and recorded, while it + // remains inside the approval window. Upload must still be refused. + handoff, err := NewAuditHandoff(fixtureStateDirectory(t), fake, clock) + if err != nil { + t.Fatal(err) + } + if _, err := handoff.Bootstrap(context.Background(), envelope); !errors.Is(err, ErrInvalidHandoffRecord) { + t.Fatalf("Bootstrap() with clock regression before upload error = %v", err) + } + if fake.count("create-bucket") != 1 { + t.Fatalf("create count = %d, want 1", fake.count("create-bucket")) + } + envelopeName, _ := BootstrapEnvelopeObjectName(envelope.Environment(), envelope.OperationID()) + if fake.count("upload:"+envelopeName.String()) != 0 || fake.count("lifecycle") != 0 { + t.Fatalf("provider mutation continued after clock regression: %v", fake.calls) + } +} + +func TestAuditHandoffReverifiesCompletedBootstrapAndRejectsByteDrift(t *testing.T) { + t.Parallel() + fixture := newHandoffFixture(t) + ctx := context.Background() + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); err != nil { + t.Fatal(err) + } + envelopeName, _ := BootstrapEnvelopeObjectName(fixture.envelope.Environment(), fixtureOperationID) + stored := fixture.fake.objects[fixture.identity.Name][envelopeName.String()] + // Same size and CRC32C descriptor, different bytes: a checksum collision + // must not pass as the exact envelope. + stored.content = append([]byte(nil), stored.content...) + stored.content[10] ^= 0x01 + fixture.fake.objects[fixture.identity.Name][envelopeName.String()] = stored + if _, err := fixture.handoff.Bootstrap(ctx, fixture.envelope); !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("completed Bootstrap() over drifted bytes error = %v", err) + } + if _, err := fixture.handoff.LockRetention(ctx, fixture.envelope); !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("LockRetention() over drifted bytes error = %v", err) + } + vanished := newHandoffFixture(t) + if _, err := vanished.handoff.Bootstrap(ctx, vanished.envelope); err != nil { + t.Fatal(err) + } + delete(vanished.fake.buckets, vanished.identity.Name) + if _, err := vanished.handoff.Bootstrap(ctx, vanished.envelope); !errors.Is(err, ErrPartialBootstrapBlocked) { + t.Fatalf("completed Bootstrap() over a vanished bucket error = %v", err) + } +} diff --git a/internal/control/iam.go b/internal/control/iam.go new file mode 100644 index 0000000..b3ab7fa --- /dev/null +++ b/internal/control/iam.go @@ -0,0 +1,171 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/thelostorbital/ctrldb/internal/isolation/bootstrap" +) + +const ( + RoleObjectUser = "roles/storage.objectUser" + RoleObjectViewer = "roles/storage.objectViewer" + RoleObjectCreator = "roles/storage.objectCreator" + + // TestPrefix is the disposable subtree that every harness identity is + // confined to on both buckets (SECURITY §2.9, ARCHITECTURE layout). + TestPrefix = "test/" + // WipePrefix is where the nightly wipe writes its run records. + WipePrefix = "test/wipe/" +) + +var ( + // ErrInvalidBucketBinding is returned when a binding is not exactly + // resource-scoped to one approved bucket and one closed prefix. + ErrInvalidBucketBinding = errors.New("invalid bucket IAM binding") + + closedBindingRoles = map[string]struct{}{RoleObjectUser: {}, RoleObjectViewer: {}, RoleObjectCreator: {}} + closedBindingPrefix = map[string]struct{}{TestPrefix: {}, WipePrefix: {}} + serviceAccountMember = regexp.MustCompile(`^serviceAccount:[a-z][a-z0-9-]{5,29}@[a-z][a-z0-9-]{4,28}[a-z0-9]\.iam\.gserviceaccount\.com$`) +) + +// BucketBinding is one conditional IAM binding on one approved bucket. The +// condition confines the grant to objects under Prefix; there is no +// bucket-wide grant and no setIamPolicy for any tool identity. +type BucketBinding struct { + Bucket string `json:"bucket"` + Role string `json:"role"` + Member string `json:"member"` + Prefix string `json:"prefix"` + ConditionTitle string `json:"conditionTitle"` + ConditionExpression string `json:"conditionExpression"` +} + +// BucketPolicy is the closed rendered binding set for one step plus its +// fingerprint for the harness-state RoleBindings field. +type BucketPolicy struct { + StepID string `json:"stepId"` + Bindings []BucketBinding `json:"bindings"` + Fingerprint string `json:"fingerprint"` +} + +// RenderBucketPolicy renders exactly the K3 (both buckets) or T6 (control +// test prefix) bindings for the approved desired state. Any other step is +// rejected; the catalog is protocol-owned and not caller-extensible. +func RenderBucketPolicy(stepID string, desired bootstrap.HarnessDesiredState) (BucketPolicy, error) { + operator := "serviceAccount:" + desired.OperatorPrincipal + destructive := "serviceAccount:" + desired.DestructivePrincipal + vm := "serviceAccount:" + desired.VMPrincipal + wipe := "serviceAccount:" + desired.WipePrincipal + var bindings []BucketBinding + switch stepID { + case "k3-bucket-iam": + bindings = []BucketBinding{ + binding(desired.AuditBucket, RoleObjectCreator, operator, TestPrefix), + binding(desired.AuditBucket, RoleObjectViewer, operator, TestPrefix), + binding(desired.AuditBucket, RoleObjectCreator, destructive, TestPrefix), + binding(desired.AuditBucket, RoleObjectViewer, destructive, TestPrefix), + binding(desired.AuditBucket, RoleObjectCreator, wipe, TestPrefix), + binding(desired.ControlBucket, RoleObjectCreator, wipe, WipePrefix), + binding(desired.ControlBucket, RoleObjectViewer, wipe, TestPrefix), + binding(desired.ControlBucket, RoleObjectViewer, vm, TestPrefix), + } + case "t6-control-prefix": + bindings = []BucketBinding{ + binding(desired.ControlBucket, RoleObjectUser, operator, TestPrefix), + binding(desired.ControlBucket, RoleObjectUser, destructive, TestPrefix), + } + default: + return BucketPolicy{}, fmt.Errorf("%w: step %q renders no bucket policy", ErrInvalidBucketBinding, stepID) + } + if desired.AuditBucket == "" || desired.ControlBucket == "" || desired.AuditBucket == desired.ControlBucket { + return BucketPolicy{}, fmt.Errorf("%w: bucket identities", ErrInvalidBucketBinding) + } + for _, item := range bindings { + if err := ValidateBucketBinding(item, desired); err != nil { + return BucketPolicy{}, err + } + } + sortBindings(bindings) + fingerprint, err := hashJSON(bindings) + if err != nil { + return BucketPolicy{}, fmt.Errorf("%w: fingerprint", ErrInvalidBucketBinding) + } + return BucketPolicy{StepID: stepID, Bindings: bindings, Fingerprint: fingerprint}, nil +} + +func binding(bucket, role, member, prefix string) BucketBinding { + title := "ctrldb-" + strings.TrimSuffix(strings.ReplaceAll(prefix, "/", "-"), "-") + "-" + strings.TrimPrefix(role, "roles/storage.") + expression := fmt.Sprintf(`resource.type == "storage.googleapis.com/Object" && resource.name.startsWith("projects/_/buckets/%s/objects/%s")`, bucket, prefix) + return BucketBinding{Bucket: bucket, Role: role, Member: member, Prefix: prefix, ConditionTitle: title, ConditionExpression: expression} +} + +// ValidateBucketBinding fails closed unless the binding names one approved +// bucket, one closed role, one harness service account, one closed prefix, and +// the exact resource-scoped condition for that bucket and prefix. +func ValidateBucketBinding(item BucketBinding, desired bootstrap.HarnessDesiredState) error { + if item.Bucket == "" || (item.Bucket != desired.AuditBucket && item.Bucket != desired.ControlBucket) { + return fmt.Errorf("%w: bucket is not an approved control-plane bucket", ErrInvalidBucketBinding) + } + if _, ok := closedBindingRoles[item.Role]; !ok { + return fmt.Errorf("%w: role %q is outside the closed set", ErrInvalidBucketBinding, item.Role) + } + if _, ok := closedBindingPrefix[item.Prefix]; !ok { + return fmt.Errorf("%w: prefix is outside the disposable subtree", ErrInvalidBucketBinding) + } + if !serviceAccountMember.MatchString(item.Member) { + return fmt.Errorf("%w: member is not a project service account", ErrInvalidBucketBinding) + } + principals := map[string]struct{}{ + "serviceAccount:" + desired.OperatorPrincipal: {}, "serviceAccount:" + desired.DestructivePrincipal: {}, + "serviceAccount:" + desired.VMPrincipal: {}, "serviceAccount:" + desired.WipePrincipal: {}, + } + if _, ok := principals[item.Member]; !ok { + return fmt.Errorf("%w: member is not an approved harness identity", ErrInvalidBucketBinding) + } + expected := binding(item.Bucket, item.Role, item.Member, item.Prefix) + if item != expected { + return fmt.Errorf("%w: condition is not the exact resource-scoped expression", ErrInvalidBucketBinding) + } + return nil +} + +// CompensationBindings returns the rendered bindings that were absent before +// the operation. Compensation may remove only these; preexisting grants are +// never touched. +func CompensationBindings(rendered []BucketBinding, preexisting []BucketBinding) []BucketBinding { + existing := make(map[BucketBinding]struct{}, len(preexisting)) + for _, item := range preexisting { + existing[item] = struct{}{} + } + result := make([]BucketBinding, 0, len(rendered)) + for _, item := range rendered { + if _, present := existing[item]; !present { + result = append(result, item) + } + } + sortBindings(result) + return result +} + +func sortBindings(values []BucketBinding) { + sort.Slice(values, func(left, right int) bool { + a, b := values[left], values[right] + if a.Bucket != b.Bucket { + return a.Bucket < b.Bucket + } + if a.Role != b.Role { + return a.Role < b.Role + } + if a.Member != b.Member { + return a.Member < b.Member + } + return a.Prefix < b.Prefix + }) +} diff --git a/internal/control/seeds.go b/internal/control/seeds.go new file mode 100644 index 0000000..ba089f2 --- /dev/null +++ b/internal/control/seeds.go @@ -0,0 +1,292 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" +) + +const ( + LockRecordSchemaV1 = "ctrldb.ctrlboard.dev/lock/v1" + AdoptionRecordSchemaV1 = "ctrldb.ctrlboard.dev/adoption/v1" + ApprovedPolicySchemaV1 = "ctrldb.ctrlboard.dev/approved-policy/v1" + CostCeilingSchemaV1 = "ctrldb.ctrlboard.dev/cost-ceiling/v1" + + // LockStateReleased is the seeded lock state (ARCHITECTURE lock protocol). + LockStateReleased = "released" + // LockStateHeld is the acquired lock state. + LockStateHeld = "held" + + maxSeedObjectBytes = 64 << 10 +) + +// ErrApprovedPolicyMismatch is returned when an existing approved-policy seed +// binds a different manifest hash; WF-ENV-02 owns that change. +var ErrApprovedPolicyMismatch = errors.New("existing approved policy does not match the manifest hash; use WF-ENV-02") + +// LockRecordV1 is the `locks/.json` object. A released record carries no +// holder; hostname and pid are recorded only while held. +type LockRecordV1 struct { + Schema string `json:"schema"` + Environment string `json:"environment"` + WorkflowID string `json:"workflowId,omitempty"` + OperationID string `json:"operationId,omitempty"` + PlanID string `json:"planId,omitempty"` + Holder *LockHolder `json:"holder,omitempty"` + AcquiredAt *time.Time `json:"acquiredAt,omitempty"` + HeartbeatAt *time.Time `json:"heartbeatAt,omitempty"` + LeaseUntil *time.Time `json:"leaseUntil,omitempty"` + ExcludesHostAutomations bool `json:"excludesHostAutomations"` + State string `json:"state"` + Readers []string `json:"readers"` + CLIVersion string `json:"cliVersion,omitempty"` +} + +// LockHolder identifies the holding process. +type LockHolder struct { + Account string `json:"account"` + Impersonated string `json:"impersonated,omitempty"` + Hostname string `json:"hostname"` + PID int64 `json:"pid"` +} + +// AdoptionRecordV1 is the `adoption/.json` seed: the permanent control +// resources this bootstrap created, keyed by plan resource ID. +type AdoptionRecordV1 struct { + Schema string `json:"schema"` + Environment string `json:"environment"` + Project string `json:"project"` + OperationID string `json:"operationId"` + PlanID string `json:"planId"` + AdoptedAt time.Time `json:"adoptedAt"` + Resources map[string]AdoptedResource `json:"resources"` +} + +// AdoptedResource is one fingerprinted permanent resource. +type AdoptedResource struct { + Kind string `json:"kind"` + Name string `json:"name"` + ProviderID string `json:"providerId"` + Fingerprint string `json:"fingerprint"` +} + +// ApprovedPolicyV1 is `policy//manifest-approved.json` (D-115). +type ApprovedPolicyV1 struct { + Schema string `json:"schema"` + SHA256 string `json:"sha256"` + ApprovedBy string `json:"approvedBy"` + PlanID string `json:"planId"` +} + +// CostCeilingV1 is `policy//cost-ceiling.json` (D-102). +type CostCeilingV1 struct { + Schema string `json:"schema"` + Environment string `json:"environment"` + CeilingMicros int64 `json:"ceilingMicros"` + EstimatedRunMicros int64 `json:"estimatedRunMicros"` + Currency string `json:"currency"` + ApprovedBy string `json:"approvedBy"` + PlanID string `json:"planId"` +} + +// SeedObject is one exact K4 create-only object. +type SeedObject struct { + Name ControlObjectName + Content []byte +} + +// SeedObjects renders the exact K4 seed identities and contents from the +// envelope. Nothing is derived from ambient configuration. +func SeedObjects(envelope BootstrapEnvelopeV1) ([]SeedObject, error) { + if envelope.hash == "" { + return nil, invalidEnvelope("unsealed") + } + environment := envelope.Environment() + plan := envelope.Plan() + lockName, err := LockObjectName(environment) + if err != nil { + return nil, err + } + adoptionName, err := AdoptionObjectName(environment) + if err != nil { + return nil, err + } + policyName, err := ApprovedPolicyObjectName(environment) + if err != nil { + return nil, err + } + ceilingName, err := CostCeilingObjectName(environment) + if err != nil { + return nil, err + } + adopted := make(map[string]AdoptedResource) + for _, resource := range plan.DesiredResources() { + if resource.ID == "audit-bucket" || resource.ID == "control-bucket" { + adopted[resource.ID] = AdoptedResource{Kind: string(resource.Kind), Name: resource.Name, ProviderID: resource.ProviderID, Fingerprint: resource.DesiredStateFingerprint} + } + } + if len(adopted) != 2 { + return nil, invalidEnvelope("permanent bucket resources") + } + values := []struct { + name ControlObjectName + value any + }{ + {lockName, LockRecordV1{Schema: LockRecordSchemaV1, Environment: environment, State: LockStateReleased, Readers: []string{}}}, + {adoptionName, AdoptionRecordV1{Schema: AdoptionRecordSchemaV1, Environment: environment, Project: envelope.Project(), + OperationID: envelope.OperationID(), PlanID: envelope.PlanID(), AdoptedAt: envelope.SealedAt(), Resources: adopted}}, + {policyName, ApprovedPolicyV1{Schema: ApprovedPolicySchemaV1, SHA256: plan.Binding().ManifestHash, ApprovedBy: envelope.Account(), PlanID: envelope.PlanID()}}, + {ceilingName, CostCeilingV1{Schema: CostCeilingSchemaV1, Environment: environment, CeilingMicros: plan.Limits().MaximumCostMicros, + EstimatedRunMicros: plan.Limits().EstimatedCostMicros, Currency: "USD", ApprovedBy: envelope.Account(), PlanID: envelope.PlanID()}}, + } + result := make([]SeedObject, len(values)) + for index, item := range values { + encoded, err := json.Marshal(item.value) + if err != nil { + return nil, invalidEnvelope("seed encoding") + } + result[index] = SeedObject{Name: item.name, Content: encoded} + } + return result, nil +} + +// SeedOutcome records what K4 did for one object. +type SeedOutcome struct { + Name ControlObjectName + Descriptor ObjectDescriptor + Preexisting bool +} + +// ErrSeedIncompatible is returned when an existing seed object is malformed +// or semantically incompatible with the envelope; it is preserved, never +// overwritten, and the operation blocks. +var ErrSeedIncompatible = errors.New("existing control seed object is incompatible with the approved bootstrap") + +// SeedControlStore first reads every seed location and validates each +// existing object strictly; only when no conflict exists does it create the +// absent seeds with a create-only precondition. Existing objects are preserved +// byte-for-byte. An approved-policy object bound to a different manifest hash +// blocks toward WF-ENV-02. +func SeedControlStore(ctx context.Context, store ControlStore, envelope BootstrapEnvelopeV1) ([]SeedOutcome, error) { + if store == nil { + return nil, fmt.Errorf("%w: nil store", ErrInvalidStoreRequest) + } + seeds, err := SeedObjects(envelope) + if err != nil { + return nil, err + } + existing := make([]*StoredObject, len(seeds)) + for index, seed := range seeds { + object, err := store.Read(ctx, seed.Name) + if err != nil { + if errors.Is(err, ErrObjectNotFound) { + continue + } + return nil, err + } + if err := validateExistingSeed(seed, object.Content, envelope); err != nil { + return nil, err + } + existing[index] = &object + } + outcomes := make([]SeedOutcome, 0, len(seeds)) + for index, seed := range seeds { + if existing[index] != nil { + outcomes = append(outcomes, SeedOutcome{Name: seed.Name, Descriptor: existing[index].Descriptor, Preexisting: true}) + continue + } + descriptor, err := store.Create(ctx, seed.Name, seed.Content) + if err == nil { + outcomes = append(outcomes, SeedOutcome{Name: seed.Name, Descriptor: descriptor}) + continue + } + if !errors.Is(err, ErrPreconditionFailed) { + return nil, err + } + // Lost a create race after the preflight: re-read and validate. + object, err := store.Read(ctx, seed.Name) + if err != nil { + return nil, err + } + if err := validateExistingSeed(seed, object.Content, envelope); err != nil { + return nil, err + } + outcomes = append(outcomes, SeedOutcome{Name: seed.Name, Descriptor: object.Descriptor, Preexisting: true}) + } + return outcomes, nil +} + +func validateExistingSeed(seed SeedObject, content []byte, envelope BootstrapEnvelopeV1) error { + if bytes.Equal(content, seed.Content) { + return nil + } + if len(content) == 0 || len(content) > maxSeedObjectBytes { + return fmt.Errorf("%w: %s size", ErrSeedIncompatible, seed.Name) + } + if err := rejectDuplicateKeys(content); err != nil { + return fmt.Errorf("%w: %s is ambiguous", ErrSeedIncompatible, seed.Name) + } + environment := envelope.Environment() + plan := envelope.Plan() + switch { + case strings.HasPrefix(seed.Name.value, "locks/"): + var lock LockRecordV1 + if err := decodeStrictSeed(content, &lock); err != nil || lock.Schema != LockRecordSchemaV1 || lock.Environment != environment || + (lock.State != LockStateReleased && lock.State != LockStateHeld) { + return fmt.Errorf("%w: %s", ErrSeedIncompatible, seed.Name) + } + case strings.HasPrefix(seed.Name.value, "adoption/"): + var adoption AdoptionRecordV1 + if err := decodeStrictSeed(content, &adoption); err != nil || adoption.Schema != AdoptionRecordSchemaV1 || + adoption.Environment != environment || adoption.Project != envelope.Project() || + !operationIDPattern.MatchString(adoption.OperationID) || !planIDPattern.MatchString(adoption.PlanID) || !validUTC(adoption.AdoptedAt) { + return fmt.Errorf("%w: %s", ErrSeedIncompatible, seed.Name) + } + var expected AdoptionRecordV1 + _ = json.Unmarshal(seed.Content, &expected) + if !equalCanonicalValue(adoption.Resources, expected.Resources) { + return fmt.Errorf("%w: %s records different permanent resources", ErrSeedIncompatible, seed.Name) + } + case strings.HasSuffix(seed.Name.value, "/manifest-approved.json"): + var policy ApprovedPolicyV1 + if err := decodeStrictSeed(content, &policy); err != nil || policy.Schema != ApprovedPolicySchemaV1 || + !planIDPattern.MatchString(policy.PlanID) || policy.ApprovedBy == "" { + return fmt.Errorf("%w: %s", ErrSeedIncompatible, seed.Name) + } + if policy.SHA256 != plan.Binding().ManifestHash { + return ErrApprovedPolicyMismatch + } + case strings.HasSuffix(seed.Name.value, "/cost-ceiling.json"): + var ceiling CostCeilingV1 + if err := decodeStrictSeed(content, &ceiling); err != nil || ceiling.Schema != CostCeilingSchemaV1 || ceiling.Environment != environment || + ceiling.Currency != "USD" || !planIDPattern.MatchString(ceiling.PlanID) { + return fmt.Errorf("%w: %s", ErrSeedIncompatible, seed.Name) + } + if ceiling.CeilingMicros != plan.Limits().MaximumCostMicros { + return fmt.Errorf("%w: %s", ErrApprovedPolicyMismatch, seed.Name) + } + default: + return fmt.Errorf("%w: %s", ErrSeedIncompatible, seed.Name) + } + return nil +} + +func decodeStrictSeed(content []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return errors.New("trailing data") + } + return nil +} diff --git a/internal/control/seeds_iam_test.go b/internal/control/seeds_iam_test.go new file mode 100644 index 0000000..57d74af --- /dev/null +++ b/internal/control/seeds_iam_test.go @@ -0,0 +1,218 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestSeedObjectsAreExactAndCreateOnly(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + seeds, err := SeedObjects(envelope) + if err != nil { + t.Fatalf("SeedObjects() error: %v", err) + } + wantNames := []string{"locks/disposable-test.json", "adoption/disposable-test.json", "policy/disposable-test/manifest-approved.json", "policy/disposable-test/cost-ceiling.json"} + if len(seeds) != len(wantNames) { + t.Fatalf("seeds = %d", len(seeds)) + } + for index, seed := range seeds { + if seed.Name.String() != wantNames[index] { + t.Fatalf("seed %d = %q, want %q", index, seed.Name.String(), wantNames[index]) + } + } + var lock LockRecordV1 + if err := json.Unmarshal(seeds[0].Content, &lock); err != nil || lock.State != LockStateReleased || lock.Holder != nil || lock.Readers == nil { + t.Fatalf("lock seed = %s (%v)", seeds[0].Content, err) + } + var policy ApprovedPolicyV1 + if err := json.Unmarshal(seeds[2].Content, &policy); err != nil || policy.SHA256 != envelope.Plan().Binding().ManifestHash || policy.ApprovedBy != fixtureAccount || policy.PlanID != fixturePlanID { + t.Fatalf("policy seed = %s", seeds[2].Content) + } + var ceiling CostCeilingV1 + if err := json.Unmarshal(seeds[3].Content, &ceiling); err != nil || ceiling.CeilingMicros != envelope.Plan().Limits().MaximumCostMicros || ceiling.Currency != "USD" { + t.Fatalf("ceiling seed = %s", seeds[3].Content) + } + var adoption AdoptionRecordV1 + if err := json.Unmarshal(seeds[1].Content, &adoption); err != nil || len(adoption.Resources) != 2 || adoption.Resources["audit-bucket"].Fingerprint == "" { + t.Fatalf("adoption seed = %s", seeds[1].Content) + } + again, _ := SeedObjects(envelope) + for index := range seeds { + if !bytes.Equal(seeds[index].Content, again[index].Content) { + t.Fatal("seed rendering is not deterministic") + } + } + + ctx := context.Background() + store := NewMemoryControlStore() + first, err := SeedControlStore(ctx, store, envelope) + if err != nil || len(first) != 4 { + t.Fatalf("SeedControlStore() = %v, %v", first, err) + } + for _, outcome := range first { + if outcome.Preexisting || outcome.Descriptor.Generation == 0 { + t.Fatalf("first seeding outcome = %#v", outcome) + } + } + // A concurrent holder changed the lock meanwhile: K4 must preserve it. + held, _ := store.CompareAndSwap(ctx, first[0].Name, first[0].Descriptor.Generation, []byte(`{"schema":"ctrldb.ctrlboard.dev/lock/v1","environment":"disposable-test","excludesHostAutomations":true,"state":"held","readers":[]}`)) + second, err := SeedControlStore(ctx, store, envelope) + if err != nil { + t.Fatalf("second SeedControlStore() error: %v", err) + } + for index, outcome := range second { + if !outcome.Preexisting { + t.Fatalf("second seeding re-created %s", outcome.Name) + } + want := first[index].Descriptor + if index == 0 { + want = held + } + if outcome.Descriptor != want { + t.Fatalf("second seeding changed %s: %#v", outcome.Name, outcome.Descriptor) + } + } + lockObject, _ := store.Read(ctx, first[0].Name) + if !strings.Contains(string(lockObject.Content), `"state":"held"`) { + t.Fatal("preexisting lock was reseeded") + } + + // An existing approved policy for a different manifest blocks K4. + mismatched := NewMemoryControlStore() + policyName, _ := ApprovedPolicyObjectName("disposable-test") + if _, err := mismatched.Create(ctx, policyName, []byte(`{"schema":"ctrldb.ctrlboard.dev/approved-policy/v1","sha256":"`+repeatHex("f")+`","approvedBy":"x@example.invalid","planId":"plan-fedcba9876543210"}`)); err != nil { + t.Fatal(err) + } + if _, err := SeedControlStore(ctx, mismatched, envelope); !errors.Is(err, ErrApprovedPolicyMismatch) { + t.Fatalf("mismatched policy error = %v", err) + } + if names := mismatched.Names(); len(names) != 1 { + t.Fatalf("a rejected seeding created partial state: %v", names) + } + // Existing seeds are parsed strictly and must be semantically compatible. + lockName, _ := LockObjectName("disposable-test") + adoptionName, _ := AdoptionObjectName("disposable-test") + ceilingName, _ := CostCeilingObjectName("disposable-test") + for name, existing := range map[string]struct { + object ControlObjectName + content string + want error + }{ + "duplicate policy keys": {policyName, `{"schema":"ctrldb.ctrlboard.dev/approved-policy/v1","sha256":"` + repeatHex("f") + `","sha256":"` + envelope.Plan().Binding().ManifestHash + `","approvedBy":"x@example.invalid","planId":"plan-fedcba9876543210"}`, ErrSeedIncompatible}, + "unknown policy field": {policyName, `{"schema":"ctrldb.ctrlboard.dev/approved-policy/v1","sha256":"` + envelope.Plan().Binding().ManifestHash + `","approvedBy":"x@example.invalid","planId":"plan-fedcba9876543210","extra":1}`, ErrSeedIncompatible}, + "foreign lock": {lockName, `{"schema":"ctrldb.ctrlboard.dev/lock/v1","environment":"production","excludesHostAutomations":false,"state":"released","readers":[]}`, ErrSeedIncompatible}, + "malformed lock": {lockName, `not json`, ErrSeedIncompatible}, + "foreign adoption": {adoptionName, `{"schema":"ctrldb.ctrlboard.dev/adoption/v1","environment":"disposable-test","project":"other-project","operationId":"op-fedcba9876543210","planId":"plan-fedcba9876543210","adoptedAt":"2026-09-09T12:00:00Z","resources":{}}`, ErrSeedIncompatible}, + "different ceiling": {ceilingName, `{"schema":"ctrldb.ctrlboard.dev/cost-ceiling/v1","environment":"disposable-test","ceilingMicros":1,"estimatedRunMicros":1,"currency":"USD","approvedBy":"x@example.invalid","planId":"plan-fedcba9876543210"}`, ErrApprovedPolicyMismatch}, + } { + store := NewMemoryControlStore() + if _, err := store.Create(ctx, existing.object, []byte(existing.content)); err != nil { + t.Fatal(err) + } + if _, err := SeedControlStore(ctx, store, envelope); !errors.Is(err, existing.want) { + t.Fatalf("%s error = %v, want %v", name, err, existing.want) + } + if names := store.Names(); len(names) != 1 { + t.Fatalf("%s: rejected seeding created partial state: %v", name, names) + } + after, _ := store.Read(ctx, existing.object) + if string(after.Content) != existing.content { + t.Fatalf("%s: existing object was modified", name) + } + } + // A compatible earlier bootstrap's records are preserved and accepted. + compatible := NewMemoryControlStore() + priorAdoption := strings.Replace(string(seeds[1].Content), fixtureOperationID, "op-fedcba9876543210", 1) + if _, err := compatible.Create(ctx, adoptionName, []byte(priorAdoption)); err != nil { + t.Fatal(err) + } + outcomes, err := SeedControlStore(ctx, compatible, envelope) + if err != nil || len(outcomes) != 4 || !outcomes[1].Preexisting { + t.Fatalf("compatible prior adoption = %v, %v", outcomes, err) + } + if _, err := SeedControlStore(ctx, nil, envelope); !errors.Is(err, ErrInvalidStoreRequest) { + t.Fatalf("nil store error = %v", err) + } +} + +func TestBucketPolicyIsResourceScopedAndClosed(t *testing.T) { + t.Parallel() + desired := fixturePlan(t).DesiredState() + k3, err := RenderBucketPolicy("k3-bucket-iam", desired) + if err != nil || len(k3.Bindings) != 8 || k3.Fingerprint == "" { + t.Fatalf("RenderBucketPolicy(k3) = %#v, %v", k3, err) + } + t6, err := RenderBucketPolicy("t6-control-prefix", desired) + if err != nil || len(t6.Bindings) != 2 { + t.Fatalf("RenderBucketPolicy(t6) = %#v, %v", t6, err) + } + for _, item := range append(k3.Bindings, t6.Bindings...) { + if item.Prefix == "" || !strings.Contains(item.ConditionExpression, `projects/_/buckets/`+item.Bucket+`/objects/`+item.Prefix) || + !strings.HasPrefix(item.Prefix, TestPrefix) || item.Role == "roles/storage.admin" || strings.Contains(item.Role, "legacy") { + t.Fatalf("binding is not resource-scoped: %#v", item) + } + if item.Bucket != desired.AuditBucket && item.Bucket != desired.ControlBucket { + t.Fatalf("binding names a foreign bucket: %#v", item) + } + } + for _, item := range t6.Bindings { + if item.Bucket != desired.ControlBucket || item.Role != RoleObjectUser { + t.Fatalf("T6 binding = %#v", item) + } + } + for _, item := range k3.Bindings { + if item.Bucket == desired.AuditBucket && item.Role == RoleObjectUser { + t.Fatalf("audit bucket grants overwrite capability: %#v", item) + } + } + if _, err := RenderBucketPolicy("t5-identities", desired); !errors.Is(err, ErrInvalidBucketBinding) { + t.Fatalf("foreign step rendered a policy: %v", err) + } + same := desired + same.ControlBucket = same.AuditBucket + if _, err := RenderBucketPolicy("k3-bucket-iam", same); !errors.Is(err, ErrInvalidBucketBinding) { + t.Fatalf("identical buckets accepted: %v", err) + } + + valid := k3.Bindings[0] + invalid := map[string]func(*BucketBinding){ + "foreign bucket": func(b *BucketBinding) { b.Bucket = "someone-elses-bucket" }, + "bucket-wide prefix": func(b *BucketBinding) { b.Prefix = "" }, + "production prefix": func(b *BucketBinding) { b.Prefix = "locks/" }, + "admin role": func(b *BucketBinding) { b.Role = "roles/storage.admin" }, + "user member": func(b *BucketBinding) { b.Member = "user:someone@example.invalid" }, + "foreign account": func(b *BucketBinding) { b.Member = "serviceAccount:other-sa@example-project.iam.gserviceaccount.com" }, + "loosened expression": func(b *BucketBinding) { b.ConditionExpression = `resource.type == "storage.googleapis.com/Object"` }, + "other-bucket expr": func(b *BucketBinding) { + b.ConditionExpression = strings.Replace(b.ConditionExpression, b.Bucket, "other", 1) + }, + } + for name, mutate := range invalid { + item := valid + mutate(&item) + if err := ValidateBucketBinding(item, desired); !errors.Is(err, ErrInvalidBucketBinding) { + t.Fatalf("%s accepted: %v", name, err) + } + } + + preexisting := []BucketBinding{k3.Bindings[1], k3.Bindings[4]} + compensation := CompensationBindings(k3.Bindings, preexisting) + if len(compensation) != len(k3.Bindings)-2 { + t.Fatalf("compensation = %d bindings", len(compensation)) + } + for _, item := range compensation { + for _, kept := range preexisting { + if item == kept { + t.Fatalf("compensation would remove a preexisting binding: %#v", item) + } + } + } +} diff --git a/internal/control/statedir.go b/internal/control/statedir.go new file mode 100644 index 0000000..aebdd20 --- /dev/null +++ b/internal/control/statedir.go @@ -0,0 +1,276 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const ( + envelopeFilePrefix = "bootstrap-envelope-" + handoffDirPrefix = "bootstrap-handoff-" + privateFileMode = fs.FileMode(0o600) + privateDirMode = fs.FileMode(0o700) + temporaryNamePrefix = "." +) + +var ( + // ErrInvalidStateDirectory is returned when the CtrlDB state directory is + // absent, not a directory, reachable through a symlink, or not private. + ErrInvalidStateDirectory = errors.New("invalid CtrlDB state directory") + // ErrEnvelopeConflict is returned when the state directory already holds + // an envelope for the operation with a different hash. + ErrEnvelopeConflict = errors.New("bootstrap envelope conflict") + // ErrStateFileConflict is returned when an exclusive create finds the + // target already present. + ErrStateFileConflict = errors.New("state file already exists") + // ErrInvalidStateFile is returned when a state file is not a private + // regular file or cannot be read within bounds. + ErrInvalidStateFile = errors.New("invalid state file") + + stateFileNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{0,127}$`) +) + +// StateDirectory is an explicit, validated, owner-private CtrlDB state +// directory. There is no default location. +type StateDirectory struct{ path string } + +// NewStateDirectory validates an existing private directory given as an +// absolute clean path. It never creates or repairs the directory. +func NewStateDirectory(path string) (StateDirectory, error) { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return StateDirectory{}, fmt.Errorf("%w: path must be absolute and clean", ErrInvalidStateDirectory) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil || resolved != path { + return StateDirectory{}, fmt.Errorf("%w: no path component may be a symbolic link", ErrInvalidStateDirectory) + } + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + return StateDirectory{}, fmt.Errorf("%w: must be an existing owner-private directory", ErrInvalidStateDirectory) + } + return StateDirectory{path: path}, nil +} + +// Path returns the validated directory path. +func (directory StateDirectory) Path() string { return directory.path } + +func (directory StateDirectory) envelopePath(operationID string) (string, error) { + if directory.path == "" || !operationIDPattern.MatchString(operationID) { + return "", fmt.Errorf("%w: envelope path", ErrInvalidStateDirectory) + } + return filepath.Join(directory.path, envelopeFilePrefix+operationID+".json"), nil +} + +func (directory StateDirectory) handoffPath(operationID string) (string, error) { + if directory.path == "" || !operationIDPattern.MatchString(operationID) { + return "", fmt.Errorf("%w: handoff path", ErrInvalidStateDirectory) + } + return filepath.Join(directory.path, handoffDirPrefix+operationID), nil +} + +// WriteBootstrapEnvelope publishes the envelope with exclusive create, mode +// 0600, fsync, and an atomic exclusive publish. An existing envelope is never +// overwritten; there is deliberately no removal function. +func WriteBootstrapEnvelope(directory StateDirectory, envelope BootstrapEnvelopeV1) error { + encoded, err := envelope.CanonicalJSON() + if err != nil { + return err + } + target, err := directory.envelopePath(envelope.OperationID()) + if err != nil { + return err + } + if err := writePrivateFileExclusive(target, encoded); err != nil { + if errors.Is(err, ErrStateFileConflict) { + return fmt.Errorf("%w: envelope already exists", ErrEnvelopeConflict) + } + return err + } + return nil +} + +// ReadBootstrapEnvelope strictly reads and verifies the stored envelope. +func ReadBootstrapEnvelope(directory StateDirectory, operationID string) (BootstrapEnvelopeV1, error) { + target, err := directory.envelopePath(operationID) + if err != nil { + return BootstrapEnvelopeV1{}, err + } + encoded, err := readPrivateFile(target, MaxEnvelopeBytes) + if err != nil { + return BootstrapEnvelopeV1{}, err + } + envelope, err := ParseBootstrapEnvelope(encoded) + if err != nil { + return BootstrapEnvelopeV1{}, err + } + if envelope.OperationID() != operationID { + return BootstrapEnvelopeV1{}, invalidEnvelope("operation identity") + } + return envelope, nil +} + +// EnsureBootstrapEnvelope writes the envelope when absent and otherwise +// accepts only a stored envelope with the identical hash (D-158 retry rule). +func EnsureBootstrapEnvelope(directory StateDirectory, envelope BootstrapEnvelopeV1) (BootstrapEnvelopeV1, error) { + err := WriteBootstrapEnvelope(directory, envelope) + if err == nil { + return envelope, nil + } + if !errors.Is(err, ErrEnvelopeConflict) { + return BootstrapEnvelopeV1{}, err + } + stored, readErr := ReadBootstrapEnvelope(directory, envelope.OperationID()) + if readErr != nil { + return BootstrapEnvelopeV1{}, fmt.Errorf("%w: existing envelope is unreadable: %v", ErrEnvelopeConflict, readErr) + } + if stored.SHA256() != envelope.SHA256() { + return BootstrapEnvelopeV1{}, fmt.Errorf("%w: existing envelope hash differs", ErrEnvelopeConflict) + } + return stored, nil +} + +func writePrivateFileExclusive(target string, content []byte) error { + directory := filepath.Dir(target) + base := filepath.Base(target) + if !stateFileNamePattern.MatchString(base) { + return fmt.Errorf("%w: file name", ErrInvalidStateFile) + } + if _, err := os.Lstat(target); err == nil { + return fmt.Errorf("%w: %s", ErrStateFileConflict, base) + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%w: %s", ErrInvalidStateFile, base) + } + suffix := make([]byte, 8) + if _, err := rand.Read(suffix); err != nil { + return fmt.Errorf("%w: temporary name", ErrInvalidStateFile) + } + temporary := filepath.Join(directory, temporaryNamePrefix+base+".tmp-"+hex.EncodeToString(suffix)) + file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, privateFileMode) + if err != nil { + return fmt.Errorf("%w: exclusive create", ErrInvalidStateFile) + } + if err := writeAndSync(file, content); err != nil { + _ = file.Close() + _ = os.Remove(temporary) + return err + } + if err := file.Close(); err != nil { + _ = os.Remove(temporary) + return fmt.Errorf("%w: close", ErrInvalidStateFile) + } + // Link publishes atomically and fails closed when the target appeared + // meanwhile; a rename would silently replace a concurrent writer's file. + if err := os.Link(temporary, target); err != nil { + _ = os.Remove(temporary) + if errors.Is(err, fs.ErrExist) { + return fmt.Errorf("%w: %s", ErrStateFileConflict, base) + } + return fmt.Errorf("%w: publish", ErrInvalidStateFile) + } + _ = os.Remove(temporary) + return syncDirectory(directory) +} + +func writeAndSync(file *os.File, content []byte) error { + if err := file.Chmod(privateFileMode); err != nil { + return fmt.Errorf("%w: mode", ErrInvalidStateFile) + } + if _, err := file.Write(content); err != nil { + return fmt.Errorf("%w: write", ErrInvalidStateFile) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("%w: fsync", ErrInvalidStateFile) + } + return nil +} + +func syncDirectory(directory string) error { + handle, err := os.Open(directory) + if err != nil { + return fmt.Errorf("%w: directory", ErrInvalidStateFile) + } + defer func() { _ = handle.Close() }() + if err := handle.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) { + return fmt.Errorf("%w: directory fsync", ErrInvalidStateFile) + } + return nil +} + +func readPrivateFile(target string, limit int64) ([]byte, error) { + info, err := os.Lstat(target) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", ErrInvalidStateFile, fs.ErrNotExist) + } + return nil, fmt.Errorf("%w: stat", ErrInvalidStateFile) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != privateFileMode || info.Size() > limit || info.Size() == 0 { + return nil, fmt.Errorf("%w: must be a non-empty private regular file within bounds", ErrInvalidStateFile) + } + file, err := os.Open(target) + if err != nil { + return nil, fmt.Errorf("%w: open", ErrInvalidStateFile) + } + defer func() { _ = file.Close() }() + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) { + return nil, fmt.Errorf("%w: file changed during read", ErrInvalidStateFile) + } + var buffer bytes.Buffer + if _, err := buffer.ReadFrom(io.LimitReader(file, limit+1)); err != nil || int64(buffer.Len()) != info.Size() { + return nil, fmt.Errorf("%w: read", ErrInvalidStateFile) + } + return buffer.Bytes(), nil +} + +// listStateFiles returns the sorted regular-file names in directory, ignoring +// temporary publish names. +func listStateFiles(directory string) ([]string, error) { + entries, err := os.ReadDir(directory) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("%w: list", ErrInvalidStateFile) + } + result := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, temporaryNamePrefix) { + continue + } + if !entry.Type().IsRegular() || !stateFileNamePattern.MatchString(name) { + return nil, fmt.Errorf("%w: unexpected entry", ErrInvalidStateFile) + } + result = append(result, name) + } + sort.Strings(result) + return result, nil +} + +func ensurePrivateDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + if err := os.Mkdir(path, privateDirMode); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("%w: create", ErrInvalidStateDirectory) + } + info, err = os.Lstat(path) + } + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("%w: must be an owner-private directory", ErrInvalidStateDirectory) + } + return nil +} diff --git a/internal/control/statedir_test.go b/internal/control/statedir_test.go new file mode 100644 index 0000000..4c32931 --- /dev/null +++ b/internal/control/statedir_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +func TestStateDirectoryRejectsUnsafeLocations(t *testing.T) { + t.Parallel() + shared := t.TempDir() + if err := os.Chmod(shared, 0o755); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + linkParent := t.TempDir() + if err := os.Chmod(linkParent, 0o700); err != nil { + t.Fatal(err) + } + real := filepath.Join(linkParent, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, filepath.Join(linkParent, "link")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(real, "state"), 0o700); err != nil { + t.Fatal(err) + } + for name, path := range map[string]string{ + "relative": "state", "unclean": t.TempDir() + "/./", "missing": filepath.Join(t.TempDir(), "missing"), + "group readable": shared, "regular file": file, + "symlink component": filepath.Join(linkParent, "link", "state"), + } { + t.Run(name, func(t *testing.T) { + if _, err := NewStateDirectory(path); !errors.Is(err, ErrInvalidStateDirectory) { + t.Fatalf("NewStateDirectory(%q) error = %v", path, err) + } + }) + } +} + +func TestEnvelopeWriteIsExclusivePrivateAndDurable(t *testing.T) { + t.Parallel() + directory := fixtureStateDirectory(t) + envelope := fixtureEnvelope(t) + if err := WriteBootstrapEnvelope(directory, envelope); err != nil { + t.Fatalf("WriteBootstrapEnvelope() error: %v", err) + } + path := filepath.Join(directory.Path(), "bootstrap-envelope-"+fixtureOperationID+".json") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("envelope file = %v, %v", info, err) + } + entries, _ := os.ReadDir(directory.Path()) + if len(entries) != 1 { + t.Fatalf("temporary publish names remain: %v", entries) + } + if err := WriteBootstrapEnvelope(directory, envelope); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("second write error = %v, want ErrEnvelopeConflict", err) + } + stored, err := ReadBootstrapEnvelope(directory, fixtureOperationID) + if err != nil || stored.SHA256() != envelope.SHA256() { + t.Fatalf("ReadBootstrapEnvelope() = %v, %v", stored.SHA256(), err) + } + ensured, err := EnsureBootstrapEnvelope(directory, envelope) + if err != nil || ensured.SHA256() != envelope.SHA256() { + t.Fatalf("EnsureBootstrapEnvelope(same) = %v, %v", ensured.SHA256(), err) + } + + // A retry with any other envelope hash for the same operation is refused. + seed := fixtureSeed(t) + seed.SealedAt = seed.SealedAt.Add(time.Second) + different, err := SealBootstrapEnvelope(seed) + if err != nil || different.SHA256() == envelope.SHA256() { + t.Fatalf("could not build a differing envelope: %v", err) + } + if _, err := EnsureBootstrapEnvelope(directory, different); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("EnsureBootstrapEnvelope(different) error = %v", err) + } + if _, err := ReadBootstrapEnvelope(directory, "op-fedcba9876543210"); !errors.Is(err, ErrInvalidStateFile) || !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("missing envelope error = %v", err) + } +} + +func TestEnvelopeReadRejectsTamperedOrUnsafeFiles(t *testing.T) { + t.Parallel() + envelope := fixtureEnvelope(t) + encoded, _ := envelope.CanonicalJSON() + path := func(directory StateDirectory) string { + return filepath.Join(directory.Path(), "bootstrap-envelope-"+fixtureOperationID+".json") + } + + tampered := fixtureStateDirectory(t) + mutated := append([]byte(nil), encoded...) + mutated[len(mutated)/2] ^= 0x02 + if err := os.WriteFile(path(tampered), mutated, 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadBootstrapEnvelope(tampered, fixtureOperationID); !errors.Is(err, ErrInvalidEnvelope) { + t.Fatalf("tampered read error = %v", err) + } + if _, err := EnsureBootstrapEnvelope(tampered, envelope); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("ensure over tampered error = %v", err) + } + + loose := fixtureStateDirectory(t) + if err := os.WriteFile(path(loose), encoded, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadBootstrapEnvelope(loose, fixtureOperationID); !errors.Is(err, ErrInvalidStateFile) { + t.Fatalf("world-readable read error = %v", err) + } + + linked := fixtureStateDirectory(t) + target := filepath.Join(t.TempDir(), "elsewhere.json") + if err := os.WriteFile(target, encoded, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path(linked)); err != nil { + t.Fatal(err) + } + if _, err := ReadBootstrapEnvelope(linked, fixtureOperationID); !errors.Is(err, ErrInvalidStateFile) { + t.Fatalf("symlink read error = %v", err) + } + if err := WriteBootstrapEnvelope(linked, envelope); !errors.Is(err, ErrEnvelopeConflict) { + t.Fatalf("write over symlink error = %v", err) + } + + empty := fixtureStateDirectory(t) + if err := os.WriteFile(path(empty), nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadBootstrapEnvelope(empty, fixtureOperationID); !errors.Is(err, ErrInvalidStateFile) { + t.Fatalf("empty read error = %v", err) + } +} diff --git a/internal/control/store.go b/internal/control/store.go new file mode 100644 index 0000000..caa2cc6 --- /dev/null +++ b/internal/control/store.go @@ -0,0 +1,334 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "context" + "errors" + "fmt" + "hash/crc32" + "strings" + "sync" + + "github.com/thelostorbital/ctrldb/internal/domain" + "github.com/thelostorbital/ctrldb/internal/workflow" +) + +var ( + // ErrInvalidObjectName is returned when a name cannot be formed from the + // closed ARCHITECTURE prefix layout. + ErrInvalidObjectName = errors.New("invalid control-plane object name") + // ErrObjectNotFound is returned by reads of an absent object. + ErrObjectNotFound = errors.New("control-plane object not found") + // ErrPreconditionFailed is returned when a create finds an existing object + // or a compare-and-swap generation no longer matches. + ErrPreconditionFailed = errors.New("control-plane generation precondition failed") + // ErrInvalidStoreRequest is returned for an unusable store argument. + ErrInvalidStoreRequest = errors.New("invalid control-plane store request") + + crc32cTable = crc32.MakeTable(crc32.Castagnoli) +) + +// Generation is a server object generation. Zero means "must not exist". +type Generation uint64 + +// ObjectDescriptor is the integrity view of one stored object: the server +// generation plus the size and CRC32C that `objects describe` exposes. +type ObjectDescriptor struct { + Generation Generation `json:"generation"` + Size int64 `json:"size"` + CRC32C uint32 `json:"crc32c"` +} + +// DescribeContent computes the descriptor fields a compliant store must report +// for content, excluding the server-assigned generation. +func DescribeContent(content []byte) ObjectDescriptor { + return ObjectDescriptor{Size: int64(len(content)), CRC32C: crc32.Checksum(content, crc32cTable)} +} + +// MatchesContent reports whether descriptor has exactly the size and CRC32C of +// content. It never treats a missing checksum as a match. +func (descriptor ObjectDescriptor) MatchesContent(content []byte) bool { + expected := DescribeContent(content) + return descriptor.Generation != 0 && descriptor.Size == expected.Size && descriptor.CRC32C == expected.CRC32C +} + +// StoredObject is one read result. +type StoredObject struct { + Descriptor ObjectDescriptor + Content []byte +} + +// ControlObjectName is a validated name in the mutable control bucket. It can +// be produced only by the typed constructors below. +type ControlObjectName struct{ value string } + +func (name ControlObjectName) String() string { return name.value } + +// AuditObjectName is a validated name in the append-only audit bucket. +type AuditObjectName struct{ value string } + +func (name AuditObjectName) String() string { return name.value } + +// LockObjectName is `locks/.json` (ARCHITECTURE lock protocol). +func LockObjectName(environment string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) { + return ControlObjectName{}, invalidName("environment") + } + return ControlObjectName{value: "locks/" + environment + ".json"}, nil +} + +// HarnessStateObjectName is the WF-TEST-01 T8 flag object. +func HarnessStateObjectName() ControlObjectName { + return ControlObjectName{value: "test/harness-state.json"} +} + +// OwnershipRecordObjectName is the D-157 durable ownership record of one +// permanent harness singleton, kept under the disposable `test/` subtree. +func OwnershipRecordObjectName(environment, resourceID string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) || !canonicalIDPattern.MatchString(resourceID) { + return ControlObjectName{}, invalidName("ownership record") + } + return ControlObjectName{value: "test/ownership/" + environment + "/" + resourceID + ".json"}, nil +} + +// LifetimeRecordObjectName is the immutable D-157 run lifetime record. +func LifetimeRecordObjectName(environment, runID string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) || !canonicalIDPattern.MatchString(runID) { + return ControlObjectName{}, invalidName("lifetime record") + } + return ControlObjectName{value: "test/lifetime/" + environment + "/" + runID + ".json"}, nil +} + +// AdoptionObjectName is `adoption/.json` (K4 seed). +func AdoptionObjectName(environment string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) { + return ControlObjectName{}, invalidName("environment") + } + return ControlObjectName{value: "adoption/" + environment + ".json"}, nil +} + +// ApprovedPolicyObjectName is `policy//manifest-approved.json` (K4 seed). +func ApprovedPolicyObjectName(environment string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) { + return ControlObjectName{}, invalidName("environment") + } + return ControlObjectName{value: "policy/" + environment + "/manifest-approved.json"}, nil +} + +// CostCeilingObjectName is `policy//cost-ceiling.json` (K4 seed). +func CostCeilingObjectName(environment string) (ControlObjectName, error) { + if !environmentPattern.MatchString(environment) { + return ControlObjectName{}, invalidName("environment") + } + return ControlObjectName{value: "policy/" + environment + "/cost-ceiling.json"}, nil +} + +// PlanObjectName is the immutable `plans//.json` audit object. +func PlanObjectName(environment, planID string) (AuditObjectName, error) { + if !environmentPattern.MatchString(environment) || !planIDPattern.MatchString(planID) { + return AuditObjectName{}, invalidName("plan") + } + return AuditObjectName{value: "plans/" + environment + "/" + planID + ".json"}, nil +} + +// PlanApprovalObjectName is `plans//-approval.json`. +func PlanApprovalObjectName(environment, planID string) (AuditObjectName, error) { + if !environmentPattern.MatchString(environment) || !planIDPattern.MatchString(planID) { + return AuditObjectName{}, invalidName("plan approval") + } + return AuditObjectName{value: "plans/" + environment + "/" + planID + "-approval.json"}, nil +} + +// OperationRecordObjectName is the final `operations//.json` record. +func OperationRecordObjectName(environment, operationID string) (AuditObjectName, error) { + if !environmentPattern.MatchString(environment) || !operationIDPattern.MatchString(operationID) { + return AuditObjectName{}, invalidName("operation record") + } + return AuditObjectName{value: "operations/" + environment + "/" + operationID + ".json"}, nil +} + +// BootstrapEnvelopeObjectName is the audit copy of the D-158 envelope, kept +// beside the operation's journal entries. +func BootstrapEnvelopeObjectName(environment, operationID string) (AuditObjectName, error) { + if !environmentPattern.MatchString(environment) || !operationIDPattern.MatchString(operationID) { + return AuditObjectName{}, invalidName("bootstrap envelope") + } + return AuditObjectName{value: "operations/" + environment + "/" + operationID + "/bootstrap-envelope.json"}, nil +} + +// JournalEntryObjectName is `operations///steps/-.json` +// using the workflow package's lexically sortable immutable file name. +func JournalEntryObjectName(environment string, entry domain.JournalEntry) (AuditObjectName, error) { + if !environmentPattern.MatchString(environment) { + return AuditObjectName{}, invalidName("environment") + } + fileName, err := workflow.JournalObjectName(entry) + if err != nil { + return AuditObjectName{}, invalidName("journal entry") + } + return AuditObjectName{value: "operations/" + environment + "/" + entry.OperationID + "/steps/" + fileName}, nil +} + +// ControlStore is the normal generation-preconditioned durable store. It has +// no unconstrained delete: every write is create-only or a compare-and-swap. +type ControlStore interface { + Create(ctx context.Context, name ControlObjectName, content []byte) (ObjectDescriptor, error) + Read(ctx context.Context, name ControlObjectName) (StoredObject, error) + CompareAndSwap(ctx context.Context, name ControlObjectName, expected Generation, content []byte) (ObjectDescriptor, error) +} + +// AuditStore is the append-only store. It deliberately has no overwrite, +// compare-and-swap, or delete method. +type AuditStore interface { + Create(ctx context.Context, name AuditObjectName, content []byte) (ObjectDescriptor, error) + Read(ctx context.Context, name AuditObjectName) (StoredObject, error) +} + +type memoryObject struct { + descriptor ObjectDescriptor + content []byte +} + +type memoryObjects struct { + mutex sync.Mutex + generation Generation + objects map[string]memoryObject +} + +func (store *memoryObjects) create(name string, content []byte) (ObjectDescriptor, error) { + if name == "" { + return ObjectDescriptor{}, ErrInvalidStoreRequest + } + store.mutex.Lock() + defer store.mutex.Unlock() + if _, exists := store.objects[name]; exists { + return ObjectDescriptor{}, fmt.Errorf("%w: %s exists", ErrPreconditionFailed, name) + } + return store.put(name, content), nil +} + +func (store *memoryObjects) put(name string, content []byte) ObjectDescriptor { + store.generation++ + descriptor := DescribeContent(content) + descriptor.Generation = store.generation + store.objects[name] = memoryObject{descriptor: descriptor, content: append([]byte(nil), content...)} + return descriptor +} + +func (store *memoryObjects) read(name string) (StoredObject, error) { + if name == "" { + return StoredObject{}, ErrInvalidStoreRequest + } + store.mutex.Lock() + defer store.mutex.Unlock() + object, exists := store.objects[name] + if !exists { + return StoredObject{}, fmt.Errorf("%w: %s", ErrObjectNotFound, name) + } + return StoredObject{Descriptor: object.descriptor, Content: append([]byte(nil), object.content...)}, nil +} + +func (store *memoryObjects) compareAndSwap(name string, expected Generation, content []byte) (ObjectDescriptor, error) { + if name == "" || expected == 0 { + return ObjectDescriptor{}, ErrInvalidStoreRequest + } + store.mutex.Lock() + defer store.mutex.Unlock() + object, exists := store.objects[name] + if !exists { + return ObjectDescriptor{}, fmt.Errorf("%w: %s", ErrObjectNotFound, name) + } + if object.descriptor.Generation != expected { + return ObjectDescriptor{}, fmt.Errorf("%w: %s generation moved", ErrPreconditionFailed, name) + } + return store.put(name, content), nil +} + +func (store *memoryObjects) names() []string { + store.mutex.Lock() + defer store.mutex.Unlock() + result := make([]string, 0, len(store.objects)) + for name := range store.objects { + result = append(result, name) + } + return result +} + +// MemoryControlStore is the in-memory ControlStore used by tests and by the +// I/O-free orchestration core. +type MemoryControlStore struct{ objects memoryObjects } + +// NewMemoryControlStore returns an empty store with monotonic generations. +func NewMemoryControlStore() *MemoryControlStore { + return &MemoryControlStore{objects: memoryObjects{objects: make(map[string]memoryObject)}} +} + +func (store *MemoryControlStore) Create(ctx context.Context, name ControlObjectName, content []byte) (ObjectDescriptor, error) { + if err := storeContext(ctx); err != nil { + return ObjectDescriptor{}, err + } + return store.objects.create(name.value, content) +} + +func (store *MemoryControlStore) Read(ctx context.Context, name ControlObjectName) (StoredObject, error) { + if err := storeContext(ctx); err != nil { + return StoredObject{}, err + } + return store.objects.read(name.value) +} + +func (store *MemoryControlStore) CompareAndSwap(ctx context.Context, name ControlObjectName, expected Generation, content []byte) (ObjectDescriptor, error) { + if err := storeContext(ctx); err != nil { + return ObjectDescriptor{}, err + } + return store.objects.compareAndSwap(name.value, expected, content) +} + +// Names lists stored object names for assertions. It exposes no mutation. +func (store *MemoryControlStore) Names() []string { return store.objects.names() } + +// MemoryAuditStore is the in-memory append-only AuditStore. +type MemoryAuditStore struct{ objects memoryObjects } + +// NewMemoryAuditStore returns an empty append-only store. +func NewMemoryAuditStore() *MemoryAuditStore { + return &MemoryAuditStore{objects: memoryObjects{objects: make(map[string]memoryObject)}} +} + +func (store *MemoryAuditStore) Create(ctx context.Context, name AuditObjectName, content []byte) (ObjectDescriptor, error) { + if err := storeContext(ctx); err != nil { + return ObjectDescriptor{}, err + } + return store.objects.create(name.value, content) +} + +func (store *MemoryAuditStore) Read(ctx context.Context, name AuditObjectName) (StoredObject, error) { + if err := storeContext(ctx); err != nil { + return StoredObject{}, err + } + return store.objects.read(name.value) +} + +// Names lists stored object names for assertions. It exposes no mutation. +func (store *MemoryAuditStore) Names() []string { return store.objects.names() } + +var ( + _ ControlStore = (*MemoryControlStore)(nil) + _ AuditStore = (*MemoryAuditStore)(nil) +) + +func storeContext(ctx context.Context) error { + if ctx == nil { + return ErrInvalidStoreRequest + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidStoreRequest, err) + } + return nil +} + +func invalidName(field string) error { + return fmt.Errorf("%w: %s", ErrInvalidObjectName, strings.TrimSpace(field)) +} diff --git a/internal/control/store_test.go b/internal/control/store_test.go new file mode 100644 index 0000000..76b9b9f --- /dev/null +++ b/internal/control/store_test.go @@ -0,0 +1,215 @@ +// Copyright 2026 CtrlBoard.dev +// SPDX-License-Identifier: Apache-2.0 + +package control + +import ( + "context" + "errors" + "reflect" + "sort" + "sync" + "testing" + + "github.com/thelostorbital/ctrldb/internal/domain" +) + +func TestStoreInterfacesExposeNoDeleteOrOverwriteSurface(t *testing.T) { + t.Parallel() + methods := func(value any) []string { + kind := reflect.TypeOf(value).Elem() + result := make([]string, 0, kind.NumMethod()) + for index := 0; index < kind.NumMethod(); index++ { + result = append(result, kind.Method(index).Name) + } + sort.Strings(result) + return result + } + if got := methods((*AuditStore)(nil)); !reflect.DeepEqual(got, []string{"Create", "Read"}) { + t.Fatalf("AuditStore methods = %v", got) + } + if got := methods((*ControlStore)(nil)); !reflect.DeepEqual(got, []string{"CompareAndSwap", "Create", "Read"}) { + t.Fatalf("ControlStore methods = %v", got) + } + if got := methods((*AuditBucketPort)(nil)); !reflect.DeepEqual(got, []string{ + "ConfigureArchiveLifecycle", "ConfigureRetention", "CreateAuditBucket", "DescribeBucket", "DescribeObject", "LockRetention", "ReadObject", "UploadCreateOnly", + }) { + t.Fatalf("AuditBucketPort methods = %v", got) + } + for _, kind := range []reflect.Type{reflect.TypeOf(&MemoryAuditStore{}), reflect.TypeOf(&MemoryControlStore{})} { + for index := 0; index < kind.NumMethod(); index++ { + name := kind.Method(index).Name + if name == "Delete" || name == "Remove" || name == "Overwrite" || name == "Put" || name == "Write" { + t.Fatalf("%s exposes %s", kind, name) + } + } + } +} + +func TestObjectNamesFollowArchitecturePrefixes(t *testing.T) { + t.Parallel() + plan := fixturePlan(t) + entry := fixtureJournalEntry(plan, fixtureNow) + cases := []struct { + name string + got func() (string, error) + want string + }{ + {"lock", func() (string, error) { n, err := LockObjectName("prod"); return n.String(), err }, "locks/prod.json"}, + {"harness", func() (string, error) { return HarnessStateObjectName().String(), nil }, "test/harness-state.json"}, + {"ownership", func() (string, error) { + n, err := OwnershipRecordObjectName("dev", "test-network") + return n.String(), err + }, "test/ownership/dev/test-network.json"}, + {"lifetime", func() (string, error) { n, err := LifetimeRecordObjectName("dev", "run-1"); return n.String(), err }, "test/lifetime/dev/run-1.json"}, + {"adoption", func() (string, error) { n, err := AdoptionObjectName("dev"); return n.String(), err }, "adoption/dev.json"}, + {"policy", func() (string, error) { n, err := ApprovedPolicyObjectName("dev"); return n.String(), err }, "policy/dev/manifest-approved.json"}, + {"ceiling", func() (string, error) { n, err := CostCeilingObjectName("dev"); return n.String(), err }, "policy/dev/cost-ceiling.json"}, + {"plan", func() (string, error) { n, err := PlanObjectName("dev", fixturePlanID); return n.String(), err }, "plans/dev/" + fixturePlanID + ".json"}, + {"approval", func() (string, error) { n, err := PlanApprovalObjectName("dev", fixturePlanID); return n.String(), err }, "plans/dev/" + fixturePlanID + "-approval.json"}, + {"operation", func() (string, error) { + n, err := OperationRecordObjectName("dev", fixtureOperationID) + return n.String(), err + }, "operations/dev/" + fixtureOperationID + ".json"}, + {"envelope", func() (string, error) { + n, err := BootstrapEnvelopeObjectName("dev", fixtureOperationID) + return n.String(), err + }, "operations/dev/" + fixtureOperationID + "/bootstrap-envelope.json"}, + {"journal", func() (string, error) { n, err := JournalEntryObjectName("dev", entry); return n.String(), err }, "operations/dev/" + fixtureOperationID + "/steps/00000000000000000001-state-discover.json"}, + } + for _, test := range cases { + got, err := test.got() + if err != nil || got != test.want { + t.Fatalf("%s = %q, %v; want %q", test.name, got, err, test.want) + } + } + invalidEntry := entry + invalidEntry.Sequence = 0 + for name, err := range map[string]error{ + "env upper": firstError(LockObjectName("Prod")), + "env slash": firstError(AdoptionObjectName("a/b")), + "env dots": firstError(CostCeilingObjectName("..")), + "resource": firstError(OwnershipRecordObjectName("dev", "Bad/ID")), + "plan id": firstError(PlanObjectName("dev", "plan-x")), + "op id": firstError(OperationRecordObjectName("dev", "op-x")), + "journal": firstError(JournalEntryObjectName("dev", invalidEntry)), + "journal env": firstError(JournalEntryObjectName("Dev", entry)), + } { + if !errors.Is(err, ErrInvalidObjectName) { + t.Fatalf("%s error = %v", name, err) + } + } + if _, err := JournalEntryObjectName("dev", domain.JournalEntry{}); !errors.Is(err, ErrInvalidObjectName) { + t.Fatalf("empty entry error = %v", err) + } +} + +func firstError[T any](_ T, err error) error { return err } + +func TestMemoryStoresEnforceGenerationPreconditions(t *testing.T) { + t.Parallel() + ctx := context.Background() + control := NewMemoryControlStore() + lock, _ := LockObjectName("dev") + first, err := control.Create(ctx, lock, []byte(`{"state":"released"}`)) + if err != nil || first.Generation == 0 || !first.MatchesContent([]byte(`{"state":"released"}`)) { + t.Fatalf("Create() = %#v, %v", first, err) + } + if _, err := control.Create(ctx, lock, []byte("x")); !errors.Is(err, ErrPreconditionFailed) { + t.Fatalf("duplicate Create() error = %v", err) + } + if _, err := control.CompareAndSwap(ctx, lock, first.Generation+1, []byte("y")); !errors.Is(err, ErrPreconditionFailed) { + t.Fatalf("stale CompareAndSwap() error = %v", err) + } + if _, err := control.CompareAndSwap(ctx, lock, 0, []byte("y")); !errors.Is(err, ErrInvalidStoreRequest) { + t.Fatalf("zero-generation CompareAndSwap() error = %v", err) + } + if _, err := control.CompareAndSwap(ctx, HarnessStateObjectName(), first.Generation, []byte("y")); !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("absent CompareAndSwap() error = %v", err) + } + second, err := control.CompareAndSwap(ctx, lock, first.Generation, []byte(`{"state":"held"}`)) + if err != nil || second.Generation <= first.Generation { + t.Fatalf("CompareAndSwap() = %#v, %v", second, err) + } + read, err := control.Read(ctx, lock) + if err != nil || read.Descriptor != second || string(read.Content) != `{"state":"held"}` { + t.Fatalf("Read() = %#v, %v", read, err) + } + if _, err := control.Read(ctx, HarnessStateObjectName()); !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("absent Read() error = %v", err) + } + if _, err := control.Create(ctx, ControlObjectName{}, []byte("x")); !errors.Is(err, ErrInvalidStoreRequest) { + t.Fatalf("zero-value name accepted: %v", err) + } + cancelled, cancel := context.WithCancel(ctx) + cancel() + if _, err := control.Read(cancelled, lock); !errors.Is(err, ErrInvalidStoreRequest) { + t.Fatalf("cancelled context accepted: %v", err) + } + + audit := NewMemoryAuditStore() + name, _ := PlanObjectName("dev", fixturePlanID) + created, err := audit.Create(ctx, name, []byte("plan")) + if err != nil || created.Generation == 0 { + t.Fatalf("audit Create() = %#v, %v", created, err) + } + if _, err := audit.Create(ctx, name, []byte("plan2")); !errors.Is(err, ErrPreconditionFailed) { + t.Fatalf("audit overwrite error = %v", err) + } + object, err := audit.Read(ctx, name) + if err != nil || string(object.Content) != "plan" || object.Descriptor != created { + t.Fatalf("audit Read() = %#v, %v", object, err) + } + if got := audit.Names(); len(got) != 1 || got[0] != name.String() { + t.Fatalf("audit names = %v", got) + } +} + +func TestMemoryControlStoreCompareAndSwapRaceHasExactlyOneWinner(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := NewMemoryControlStore() + lock, _ := LockObjectName("dev") + base, err := store.Create(ctx, lock, []byte("released")) + if err != nil { + t.Fatal(err) + } + const contenders = 32 + var wins, losses int + var mutex sync.Mutex + var group sync.WaitGroup + for index := 0; index < contenders; index++ { + group.Add(1) + go func(holder int) { + defer group.Done() + _, err := store.CompareAndSwap(ctx, lock, base.Generation, []byte{byte(holder)}) + mutex.Lock() + defer mutex.Unlock() + switch { + case err == nil: + wins++ + case errors.Is(err, ErrPreconditionFailed): + losses++ + default: + t.Errorf("unexpected error %v", err) + } + }(index) + } + group.Wait() + if wins != 1 || losses != contenders-1 { + t.Fatalf("wins = %d, losses = %d", wins, losses) + } +} + +func TestObjectDescriptorNeverMatchesWithoutGeneration(t *testing.T) { + t.Parallel() + content := []byte("content") + descriptor := DescribeContent(content) + if descriptor.MatchesContent(content) { + t.Fatal("descriptor without a generation matched") + } + descriptor.Generation = 7 + if !descriptor.MatchesContent(content) || descriptor.MatchesContent([]byte("content!")) { + t.Fatal("descriptor matching is not exact") + } +}