fix(thothctl): harden workspace result and candidate publication
This commit is contained in:
@@ -458,7 +458,19 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResultProjector is an optional host-side projection applied to the validated
|
||||
// child result before any host-side candidate is published. It is deliberately
|
||||
// a callback so workspaceops does not depend on the CLI's redaction policy.
|
||||
type ResultProjector func(Result) (Result, error)
|
||||
|
||||
// Run preserves the original API for callers that do not need a public projection.
|
||||
func Run(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader) (Result, error) {
|
||||
return RunWithProjector(ctx, installation, runner, command, stdin, nil)
|
||||
}
|
||||
|
||||
// RunWithProjector validates the optional projected envelope before publishing
|
||||
// any host export. This ordering is part of the workspace boundary contract.
|
||||
func RunWithProjector(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader, projector ResultProjector) (Result, error) {
|
||||
env, generated, e := makeInput(command)
|
||||
if e != nil {
|
||||
return Result{}, e
|
||||
@@ -536,6 +548,13 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
|
||||
return Result{}, runErr
|
||||
}
|
||||
}
|
||||
if projector != nil {
|
||||
projected, projectErr := projector(result)
|
||||
if projectErr != nil || validateResult(projected, env.WorkspaceID, operationName(command)) != nil {
|
||||
return Result{}, errors.New("invalid workspace result")
|
||||
}
|
||||
result = projected
|
||||
}
|
||||
if hasExport {
|
||||
if export == nil {
|
||||
return Result{}, errors.New("invalid candidate export")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -616,3 +617,38 @@ func TestRunRejectsTrailingOrUnknownResultEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProjectsBeforePublishingCandidate(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := []byte("candidates: []\n")
|
||||
digest := DigestBytes(candidate)
|
||||
result := validWorkspaceResult("succeeded", "ok")
|
||||
result.Operation = "suggest-fks"
|
||||
result.RunID = strings.Repeat("b", 32)
|
||||
result.ArtifactIdentities = []ArtifactIdentity{{Kind: "fk-candidates", Digest: digest}}
|
||||
envelope := map[string]any{"schemaVersion": result.SchemaVersion, "status": result.Status, "code": result.Code,
|
||||
"workspaceId": result.WorkspaceID, "workspaceRevision": result.WorkspaceRevision, "descriptorBlob": result.DescriptorBlob,
|
||||
"operation": result.Operation, "runId": result.RunID, "completedStages": []string{"safe\nforged"},
|
||||
"artifactIdentities": result.ArtifactIdentities,
|
||||
"hostExport": hostExport{MediaType: "application/yaml", SHA256: digest, ContentBase64: base64.StdEncoding.EncodeToString(candidate)}}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := filepath.Join(root, "docker")
|
||||
if err := os.WriteFile(fake, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '"+string(payload)+"'\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := filepath.Join(root, "candidate.yaml")
|
||||
_, err = RunWithProjector(context.Background(), config.Installation{ProjectDirectory: root}, compose.NewRunner(fake), SuggestFksRequest{WorkspaceID: "psd", Output: output}, nil,
|
||||
func(result Result) (Result, error) { return Result{}, errors.New("unsafe projected result") })
|
||||
if err == nil {
|
||||
t.Fatal("accepted a rejected public projection")
|
||||
}
|
||||
if _, statErr := os.Stat(output); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("candidate was published before projection rejection: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user