Skip to content

Commit d13197e

Browse files
committed
fix: bind flow inputs exactly
1 parent f39faec commit d13197e

6 files changed

Lines changed: 139 additions & 6 deletions

File tree

boatstack/cmd/boatstack-helper/flow_command.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
153153
writes = append(writes, boatstackruntime.ProjectionWrite{
154154
Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactPrevious, PublishLast: true,
155155
})
156+
if err := rejectProjectionInputOverlap(lockPath, writes, removals); err != nil {
157+
return err
158+
}
156159
expectations := []boatstackruntime.ProjectionExpectation{
157160
{Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)},
158161
{Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)},
@@ -165,6 +168,21 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error {
165168
return renderFlowResult("compiled", artifactPath, artifact)
166169
}
167170

171+
func rejectProjectionInputOverlap(lockPath string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error {
172+
lockPath = filepath.Clean(lockPath)
173+
for _, write := range writes {
174+
if filepath.Clean(write.Path) == lockPath {
175+
return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a projection output")
176+
}
177+
}
178+
for _, removal := range removals {
179+
if filepath.Clean(removal.Path) == lockPath {
180+
return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a retired projection output")
181+
}
182+
}
183+
return nil
184+
}
185+
168186
func requireUnchangedCompileInput(path string, expected []byte) error {
169187
current, err := os.ReadFile(path)
170188
if err != nil || !bytes.Equal(current, expected) {

boatstack/cmd/boatstack-helper/flow_runtime.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions,
7171
if err != nil {
7272
return commandOptions{}, err
7373
}
74-
runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID)
74+
planRaw, err := os.ReadFile(plan)
75+
if err != nil {
76+
return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err)
77+
}
78+
planDigest := sha256.Sum256(planRaw)
79+
runID := flowRunID(repository, compiled.Fingerprint, options.entryID, deliveryID, hex.EncodeToString(planDigest[:]))
7580
if options.runID != "" && options.runID != runID {
7681
return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and worktree")
7782
}
@@ -336,8 +341,8 @@ func findEntry(entries []controlprogram.Entry, id string) (controlprogram.Entry,
336341
return controlprogram.Entry{}, false
337342
}
338343

339-
func flowRunID(repository, fingerprint, entry, delivery string) string {
340-
value := strings.Join([]string{repository, fingerprint, entry, delivery}, "\x00")
344+
func flowRunID(repository, fingerprint, entry, delivery, planFingerprint string) string {
345+
value := strings.Join([]string{repository, fingerprint, entry, delivery, planFingerprint}, "\x00")
341346
digest := sha256.Sum256([]byte(value))
342347
return "run-" + hex.EncodeToString(digest[:16])
343348
}

boatstack/cmd/boatstack-helper/flow_runtime_test.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,51 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) {
345345
}
346346
}
347347

348+
func TestFlowCompileRejectsDependencyLockProjectionOverlap(t *testing.T) {
349+
// control-law: compile-inputs-cannot-be-replaced-or-retired-by-their-own-projection
350+
if runtime.GOOS == "windows" {
351+
t.Skip("shell fixture is Unix-only")
352+
}
353+
repository, err := filepath.EvalSymlinks(t.TempDir())
354+
if err != nil {
355+
t.Fatal(err)
356+
}
357+
documentRaw, err := json.Marshal(productDeliveryDocument("product-delivery"))
358+
if err != nil {
359+
t.Fatal(err)
360+
}
361+
sourcePath := ".boatstack/flows/product-delivery.flow.ts"
362+
artifactPath := ".boatstack/flows/product-delivery.flow.ir.json"
363+
writeFixture(t, repository, ".git/keep", nil)
364+
writeFixture(t, repository, sourcePath, []byte("declarative source"))
365+
writeFixture(t, repository, "package-lock.json", []byte("lock"))
366+
writeFixture(t, repository, "raw-ir.json", documentRaw)
367+
frontend := filepath.Join(repository, "frontend.sh")
368+
if err := os.WriteFile(frontend, []byte("#!/bin/sh\ncat >/dev/null\ncat '"+filepath.Join(repository, "raw-ir.json")+"'\n"), 0o700); err != nil {
369+
t.Fatal(err)
370+
}
371+
options := flowCommandOptions{repository: repository, source: sourcePath, lock: "package-lock.json", frontend: frontend}
372+
if err := compileFlow(context.Background(), options); err != nil {
373+
t.Fatal(err)
374+
}
375+
before, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(artifactPath)))
376+
if err != nil {
377+
t.Fatal(err)
378+
}
379+
options.lock = artifactPath
380+
err = compileFlow(context.Background(), options)
381+
if err == nil || !strings.Contains(err.Error(), "FLOW_COMPILE_INPUT_OVERLAP") {
382+
t.Fatalf("overlapping lock result = %v", err)
383+
}
384+
after, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(artifactPath)))
385+
if err != nil || !bytes.Equal(after, before) {
386+
t.Fatalf("overlapping compile changed artifact: %v", err)
387+
}
388+
if err := checkFlow(context.Background(), flowCommandOptions{repository: repository}); err != nil {
389+
t.Fatalf("preserved artifact no longer checks: %v", err)
390+
}
391+
}
392+
348393
func TestFlowCompileRefusesUnmanagedGeneratedSkill(t *testing.T) {
349394
// control-law: first-compile-cannot-adopt-or-overwrite-unmanaged-skill-bytes
350395
if runtime.GOOS == "windows" {
@@ -693,7 +738,6 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) {
693738
}
694739
writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("exact plan"))
695740
writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("other plan"))
696-
writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("approved amendment"))
697741
resumed, err := bindFlowEntry(context.Background(), commandOptions{
698742
repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex",
699743
deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create",
@@ -713,6 +757,28 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) {
713757
}
714758
}
715759

760+
func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) {
761+
// control-law: one-flow-run-binds-the-exact-selected-plan-bytes
762+
repository := flowRepository(t)
763+
planPath := ".boatstack/plans/inbox/delivery-one.md"
764+
writeFixture(t, repository, planPath, []byte("plan A"))
765+
initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"})
766+
if err != nil {
767+
t.Fatal(err)
768+
}
769+
writeFixture(t, repository, planPath, []byte("plan B"))
770+
_, err = bindFlowEntry(context.Background(), commandOptions{
771+
repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex",
772+
deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create",
773+
})
774+
if err == nil || !strings.Contains(err.Error(), "FLOW_RUN_MISMATCH") {
775+
t.Fatalf("plan substitution result = %v", err)
776+
}
777+
if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "plans", "delivery-one.source")); !os.IsNotExist(statErr) {
778+
t.Fatalf("plan substitution produced a managed source: %v", statErr)
779+
}
780+
}
781+
716782
func TestFlowEntryPreservesSelectedPlanFilenameBeforeMaterialization(t *testing.T) {
717783
// control-law: an-admitted-plan-filename-remains-resolvable-for-the-same-run
718784
repository := flowRepository(t)

boatstack/controlprogram/artifact.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, erro
6161
}
6262

6363
func LoadArtifact(source io.Reader) (Artifact, error) {
64-
raw, err := io.ReadAll(io.LimitReader(source, 32<<20))
64+
raw, err := readLimited(source, 32<<20, "CONTROL_PROGRAM_ARTIFACT_INVALID: input exceeds 32 MiB")
6565
if err != nil {
6666
return Artifact{}, err
6767
}

boatstack/controlprogram/canonical.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type Compiled struct {
2121
}
2222

2323
func Load(source io.Reader, resolver BindingResolver) (Compiled, error) {
24-
raw, err := io.ReadAll(io.LimitReader(source, 16<<20))
24+
raw, err := readLimited(source, 16<<20, "CONTROL_PROGRAM_INVALID: input exceeds 16 MiB")
2525
if err != nil {
2626
return Compiled{}, err
2727
}
@@ -40,6 +40,17 @@ func Load(source io.Reader, resolver BindingResolver) (Compiled, error) {
4040
return Compile(document, resolver)
4141
}
4242

43+
func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error) {
44+
raw, err := io.ReadAll(io.LimitReader(source, limit+1))
45+
if err != nil {
46+
return nil, err
47+
}
48+
if int64(len(raw)) > limit {
49+
return nil, fmt.Errorf("%s", oversized)
50+
}
51+
return raw, nil
52+
}
53+
4354
func Compile(document Document, resolver BindingResolver) (Compiled, error) {
4455
if document.SchemaVersion != SchemaVersion {
4556
return Compiled{}, invalid("schema_version", "unsupported schema")

boatstack/controlprogram/canonical_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,39 @@ func TestStrictLoaderRejectsUnknownAndDuplicateFields(t *testing.T) {
119119
}
120120
}
121121

122+
func TestStrictLoadersRejectOversizedTrailingInput(t *testing.T) {
123+
// control-law: size-limited-loaders-never-treat-truncation-as-eof
124+
documentRaw, err := json.Marshal(incidentProgram())
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
compiled, err := controlprogram.Compile(incidentProgram(), nil)
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
_, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{
133+
CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("source"),
134+
DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), GeneratedSkills: map[string][]byte{},
135+
})
136+
if err != nil {
137+
t.Fatal(err)
138+
}
139+
oversized := func(raw []byte, limit int) []byte {
140+
if len(raw) >= limit {
141+
t.Fatalf("fixture length %d exceeds limit %d", len(raw), limit)
142+
}
143+
result := append([]byte(nil), raw...)
144+
result = append(result, bytes.Repeat([]byte(" "), limit-len(result))...)
145+
return append(result, 'x')
146+
}
147+
if _, err := controlprogram.Load(bytes.NewReader(oversized(documentRaw, 16<<20)), nil); err == nil || !strings.Contains(err.Error(), "exceeds 16 MiB") {
148+
t.Fatalf("oversized IR result = %v", err)
149+
}
150+
if _, err := controlprogram.LoadArtifact(bytes.NewReader(oversized(artifactRaw, 32<<20))); err == nil || !strings.Contains(err.Error(), "exceeds 32 MiB") {
151+
t.Fatalf("oversized artifact result = %v", err)
152+
}
153+
}
154+
122155
func TestCompilerRejectsUndeclaredEffectAndMissingRecovery(t *testing.T) {
123156
for name, mutate := range map[string]func(*controlprogram.Document){
124157
"undeclared-effect": func(value *controlprogram.Document) { value.Operators[0].Effects = []string{"undeclared"} },

0 commit comments

Comments
 (0)