Skip to content
Merged
1 change: 1 addition & 0 deletions boatstack/cmd/boatstack-helper/delegation_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ func prepareFlowAuthorization(ctx context.Context, request *surfaces.Request) (p
request.Authority.Receipts = append(request.Authority.Receipts, protocol.AuthorityReceipt{
ID: "delegation-" + hex.EncodeToString(receiptDigest[:8]), Class: authority,
Subject: record.Actor, Fingerprint: record.RequestFingerprint, IssuedAt: record.AuthorizedAt, ExpiresAt: record.ExpiresAt,
IdentityRole: record.ActorIdentityRole, IdentityProviderFingerprint: record.ActorIdentityProviderFingerprint,
})
}
return lock, nil, nil
Expand Down
5 changes: 4 additions & 1 deletion boatstack/cmd/boatstack-helper/flow_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
)

const flowCompilerVersion = "control-program.compiler.8"
const flowCompilerVersion = "control-program.compiler.9"

type flowCommandOptions struct {
repository string
Expand Down Expand Up @@ -54,6 +54,9 @@ func runFlowCommand(arguments []string) error {
if action == "input" {
return runFlowInput(arguments[1:])
}
if action == "planning-package" {
return runFlowPlanningPackage(arguments[1:])
}
flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError)
flags.SetOutput(os.Stderr)
options := flowCommandOptions{}
Expand Down
1 change: 1 addition & 0 deletions boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ func loadAuthority(options commandOptions, correlation string, objective model.O
fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor, options.humanIdentityRole, options.humanIdentityProviderFingerprint, string(parameterRaw)}, "\x00")))
bundle.Receipts = append(bundle.Receipts, protocol.AuthorityReceipt{
ID: "human-" + fingerprint[:16], Class: catalog.AuthorityHuman, Subject: options.humanActor, Fingerprint: fingerprint,
IdentityRole: options.humanIdentityRole, IdentityProviderFingerprint: options.humanIdentityProviderFingerprint,
IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
}
Expand Down
159 changes: 159 additions & 0 deletions boatstack/cmd/boatstack-helper/planning_package_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package main

import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/operatorstack/boatstack/boatstack/controlprogram"
"github.com/operatorstack/boatstack/boatstack/distribution"
softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery"
planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage"
)

func runFlowPlanningPackage(arguments []string) error {
if len(arguments) == 0 || arguments[0] != "verify" {
return fmt.Errorf("unknown planning-package action")
}
flags := flag.NewFlagSet("flow planning-package verify", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
var repository, deliveryID, packageFingerprint, format string
var all, requireApproval, requireCurrent bool
flags.StringVar(&repository, "repo", ".", "repository root")
flags.StringVar(&deliveryID, "delivery", "", "delivery identity")
flags.StringVar(&packageFingerprint, "package", "", "full package fingerprint")
flags.StringVar(&format, "format", "text", "text or json")
flags.BoolVar(&all, "all", false, "verify all canonical packages")
flags.BoolVar(&requireApproval, "require-approval", false, "require an exact approval")
flags.BoolVar(&requireCurrent, "require-current-program", false, "require the current checked Flow")
if err := flags.Parse(arguments[1:]); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
if format != "text" && format != "json" {
return fmt.Errorf("unsupported format %q", format)
}
repository, err := filepath.Abs(repository)
if err != nil {
return err
}
repository, err = filepath.EvalSymlinks(repository)
if err != nil {
return err
}
if all == (deliveryID != "" || packageFingerprint != "") {
return fmt.Errorf("select either --all or both --delivery and --package")
}
if !all && (deliveryID == "" || packageFingerprint == "") {
return fmt.Errorf("--delivery and --package are required together")
}
var current *planningpackage.CurrentProgram
if value, currentErr := loadCurrentPlanningProgram(context.Background(), repository); currentErr == nil {
current = &value
} else if requireCurrent {
return currentErr
}
var identities [][2]string
if all {
identities, err = planningpackage.Enumerate(repository)
} else {
identities = [][2]string{{deliveryID, packageFingerprint}}
}
if err != nil {
return err
}
results := make([]planningpackage.Result, 0, len(identities))
failed := false
for _, identity := range identities {
result := planningpackage.Verify(repository, identity[0], identity[1], current)
results = append(results, result)
if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || requireApproval && result.Approval != planningpackage.Valid || requireCurrent && result.CurrentProgram != planningpackage.Match {
failed = true
}
}
if format == "json" {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
var payload any = results
if !all {
payload = results[0]
}
if err := encoder.Encode(payload); err != nil {
return err
}
} else {
for _, result := range results {
fmt.Fprintf(os.Stdout, "%s/%s integrity=%s contract=%s approval=%s current_program=%s semantic_correctness=%s origin_authenticity=%s\n", result.DeliveryID, result.PackageFingerprint, result.Integrity, result.Contract, result.Approval, result.CurrentProgram, result.SemanticCorrectness, result.OriginAuthenticity)
}
}
if failed {
return fmt.Errorf("one or more planning packages failed required verification")
}
return nil
}

func loadCurrentPlanningProgram(ctx context.Context, repository string) (planningpackage.CurrentProgram, error) {
path, err := resolveCheckArtifact(repository, "")
if err != nil {
return planningpackage.CurrentProgram{}, err
}
raw, err := os.ReadFile(path)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw))
if err != nil {
return planningpackage.CurrentProgram{}, err
}
resolver, err := softwareflow.NewResolver(ctx)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
compiled, err := checkArtifactForCurrentProject(repository, artifact, resolver)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
definition, err := softwareflow.NewDefinition(compiled, resolver)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
program, err := distribution.ProgramForRepository(ctx, distribution.RepositoryProgramRequest{Repository: repository}, definition)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
workByID := map[string]controlprogram.WorkContract{}
for _, work := range compiled.Document.Work {
workByID[work.ID] = work
}
for _, transition := range compiled.Document.Transitions {
if transition.ID != softwareflow.PlanningPackageAdmit {
continue
}
work, ok := workByID[transition.Work]
if !ok {
return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package work is missing")
}
runtime, err := softwareflow.RuntimeWorkContract(work)
if err != nil {
return planningpackage.CurrentProgram{}, err
}
planOutput := ""
for _, binding := range transition.Parameters {
if binding.Parameter == "plan_output" && binding.Producer.Binding != nil {
planOutput = strings.TrimPrefix(binding.Producer.Binding.Reference, "software-delivery/planning-package-plan-output/")
}
}
if planOutput == "" {
return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package plan output is missing")
}
return planningpackage.CurrentProgram{ProgramFingerprint: program.Fingerprint(), WorkContractFingerprint: runtime.Fingerprint, PlanOutput: planOutput}, nil
}
return planningpackage.CurrentProgram{}, fmt.Errorf("current Flow has no planning-package admission")
}
69 changes: 69 additions & 0 deletions boatstack/cmd/boatstack-helper/planning_package_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage"
)

func TestPlanningPackageVerifyCommandReportsSeparateStatuses(t *testing.T) {
repository, deliveryID, packageFingerprint := installPlanningPackageCommandFixture(t)
output, err := captureStdout(t, func() error {
return runFlowPlanningPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--format", "json"})
})
if err != nil {
t.Fatal(err)
}
var result planningpackage.Result
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("single-package JSON is not an object: %v\n%s", err, output)
}
if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || result.Approval != planningpackage.Missing || result.CurrentProgram != planningpackage.Unavailable || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" {
t.Fatalf("verification result = %#v", result)
}
if _, err := captureStdout(t, func() error {
return runFlowPlanningPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--require-approval", "--format", "json"})
}); err == nil {
t.Fatal("missing approval satisfied --require-approval")
}
}

func installPlanningPackageCommandFixture(t *testing.T) (string, string, string) {
t.Helper()
repository, deliveryID := t.TempDir(), "proof"
plan := []byte("# plan\n")
output := planningpackage.Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: planningpackage.Digest(plan)}
work := planningpackage.WorkContract{ID: "planning", Instructions: planningpackage.Asset{Path: "package.md", SHA256: planningpackage.Digest([]byte("coordinate")), Content: "coordinate"}, Outputs: []planningpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}}
var err error
work.Fingerprint, err = planningpackage.RuntimeWorkFingerprint(work)
if err != nil {
t.Fatal(err)
}
_, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: work, PlanOutput: output.ID})
if err != nil {
t.Fatal(err)
}
programFingerprint := strings.Repeat("b", 64)
_, receiptRaw, err := planningpackage.SealWorkReceipt(planningpackage.WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("c", 64), ResultFingerprint: strings.Repeat("d", 64), ContractID: work.ID, ContractFingerprint: work.Fingerprint, TransitionID: "planning.package.admit", ProgramFingerprint: programFingerprint, ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []planningpackage.Output{output}})
if err != nil {
t.Fatal(err)
}
manifest, manifestRaw, err := planningpackage.SealManifest(planningpackage.Manifest{DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: programFingerprint, EntryID: "run", RunID: "run-proof", TransitionID: "planning.package.admit", WorkContractID: work.ID, WorkContractFingerprint: work.Fingerprint, WorkRequestFingerprint: strings.Repeat("c", 64), WorkResultFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, PlanOutput: planningpackage.PlanOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256}, Contract: planningpackage.Reference{Path: "contract.json", SHA256: planningpackage.Digest(contractRaw)}, WorkReceipt: planningpackage.Reference{Path: "work-receipt.json", SHA256: planningpackage.Digest(receiptRaw)}, Outputs: []planningpackage.Output{output}})
if err != nil {
t.Fatal(err)
}
root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, manifest.Fingerprint)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
for name, raw := range map[string][]byte{"manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, output.Path: plan} {
if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil {
t.Fatal(err)
}
}
return repository, deliveryID, manifest.Fingerprint
}
14 changes: 14 additions & 0 deletions boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/operatorstack/boatstack/boatstack/controlprogram"
softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery"
"github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage"
"github.com/operatorstack/boatstack/boatstack/internal/buildinfo"
boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog"
Expand Down Expand Up @@ -351,6 +352,19 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T
t.Fatalf("trace transition %d = %s, want %s", index+1, receipts[index].TransitionID, transitionID)
}
}
packageRoot := filepath.Join(repository, ".boatstack", "planning-packages", "todo")
packages, err := os.ReadDir(packageRoot)
if err != nil || len(packages) != 1 || !packages[0].IsDir() {
t.Fatalf("planning package inventory = %#v, err=%v", packages, err)
}
currentProgram, err := loadCurrentPlanningProgram(context.Background(), repository)
if err != nil {
t.Fatalf("load current planning program: %v", err)
}
packageVerification := planningpackage.Verify(repository, "todo", packages[0].Name(), &currentProgram)
if packageVerification.Integrity != planningpackage.Valid || packageVerification.Contract != planningpackage.Valid || packageVerification.Approval != planningpackage.Valid || packageVerification.CurrentProgram != planningpackage.Match {
t.Fatalf("portable planning package verification = %#v", packageVerification)
}
last := receipts[len(receipts)-1]
if last.TransitionID != "publication.observe" || final.Snapshot.Publication.Value != model.PublicationOpen {
t.Fatalf("final publication observation = receipt=%s state=%s", last.TransitionID, final.Snapshot.Publication.Value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) {
objective := model.Objective{ID: "objective", TargetID: "target", TrustedClass: "target", DeliveryID: "delivery"}
result, err := protocol.SealWorkEvidence(protocol.WorkEvidence{
SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: requestFingerprint,
RunID: "run-1", ProgramID: "fixture", EntryID: "run",
ContractID: contract.ID, ContractFingerprint: contract.Fingerprint, TransitionID: "planning.admit",
ProgramFingerprint: programFingerprint, ContextFingerprint: contextFingerprint, StateRevision: 3,
RepositoryID: current.RepositoryID, WorktreeID: current.WorktreeID,
Expand Down
33 changes: 30 additions & 3 deletions boatstack/controlprogram/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

const (
ArtifactSchemaName = "control-program-artifact"
ArtifactSchemaRevision = 6
ArtifactSchemaRevision = 7
)

type Artifact struct {
Expand Down Expand Up @@ -216,6 +216,9 @@ func workAssetBindings(document Document) map[string]string {
for _, contract := range document.Work {
result[contract.Instructions.Path] = contract.Instructions.SHA256
for _, output := range contract.Outputs {
if output.Guidance != nil {
result[output.Guidance.Path] = output.Guidance.SHA256
}
if output.Schema != nil {
result[output.Schema.Path] = output.Schema.SHA256
}
Expand Down Expand Up @@ -252,11 +255,35 @@ func (r RepositoryAssetResolver) ResolveAsset(path string, maxBytes int64) ([]by

func digest(value []byte) string { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) }
func safeRelative(value string) bool {
if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\\`) {
if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) || len(value) >= 2 && value[1] == ':' {
return false
}
clean := filepath.Clean(filepath.FromSlash(value))
return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.ToSlash(clean) != value {
return false
}
for _, component := range strings.Split(value, "/") {
if !portableWindowsPathComponent(component) {
return false
}
}
return true
}

func portableWindowsPathComponent(value string) bool {
if value == "" || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") || strings.ContainsAny(value, `<>:"|?*`) {
return false
}
for _, character := range value {
if character < 32 {
return false
}
}
base := strings.ToUpper(strings.SplitN(value, ".", 2)[0])
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" {
return false
}
return !(len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9')
}

func readRepositoryFile(repository, relative string) ([]byte, error) {
Expand Down
8 changes: 8 additions & 0 deletions boatstack/controlprogram/canonical.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkCon
if output.MaxBytes < 1 || output.MaxBytes > maxOutputBytes {
return nil, invalid("work."+contract.ID+".outputs."+output.ID+".max_bytes", "must be between 1 and 16 MiB")
}
if output.Guidance != nil {
if err := resolveWorkAsset(output.Guidance, assets, maxInstructionBytes, "work."+contract.ID+".outputs."+output.ID+".guidance"); err != nil {
return nil, err
}
if strings.TrimSpace(output.Guidance.Content) == "" {
return nil, invalid("work."+contract.ID+".outputs."+output.ID+".guidance", "guidance asset must not be empty")
}
}
if output.Schema != nil {
if output.MediaType != "application/json" {
return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schemas require application/json")
Expand Down
Loading
Loading