fix: harden workspace preprocessing contract
This commit is contained in:
@@ -404,7 +404,7 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
|
||||
var raw map[string]json.RawMessage
|
||||
d := json.NewDecoder(bytes.NewReader(payload))
|
||||
d.UseNumber()
|
||||
if d.Decode(&raw) != nil {
|
||||
if d.Decode(&raw) != nil || hasDuplicateJSONFields(payload) {
|
||||
return errors.New("invalid workspace request")
|
||||
}
|
||||
var extra any
|
||||
@@ -417,6 +417,12 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
|
||||
if dec.Decode(&supplied) != nil || supplied.SchemaVersion != 1 || supplied.Operation != expected.Operation || supplied.WorkspaceID != expected.WorkspaceID {
|
||||
return errors.New("invalid workspace request")
|
||||
}
|
||||
// Presence is part of the command-derived envelope contract. Decoding into Go values
|
||||
// alone would make an omitted field indistinguishable from an explicit zero/null value.
|
||||
var generatedRaw map[string]json.RawMessage
|
||||
if json.Unmarshal(mustJSON(expected), &generatedRaw) != nil || !sameJSONFieldSet(raw, generatedRaw) {
|
||||
return errors.New("workspace request does not match command")
|
||||
}
|
||||
if !reflect.DeepEqual(supplied, expected) {
|
||||
return errors.New("workspace request does not match command")
|
||||
}
|
||||
@@ -476,7 +482,7 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
d := json.NewDecoder(strings.NewReader(cr.Stdout))
|
||||
if d.Decode(&raw) != nil {
|
||||
if d.Decode(&raw) != nil || hasDuplicateJSONFields([]byte(cr.Stdout)) {
|
||||
return Result{}, errors.New("invalid workspace result")
|
||||
}
|
||||
var extra any
|
||||
@@ -490,9 +496,20 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
|
||||
if operationName(command) != "suggest-fks" {
|
||||
return Result{}, errors.New("invalid workspace result")
|
||||
}
|
||||
if json.Unmarshal(h, &export) != nil {
|
||||
var candidate hostExport
|
||||
if hasDuplicateJSONFields(h) {
|
||||
return Result{}, errors.New("invalid host export")
|
||||
}
|
||||
exportDecoder := json.NewDecoder(bytes.NewReader(h))
|
||||
exportDecoder.DisallowUnknownFields()
|
||||
if exportDecoder.Decode(&candidate) != nil {
|
||||
return Result{}, errors.New("invalid host export")
|
||||
}
|
||||
var trailing any
|
||||
if exportDecoder.Decode(&trailing) != io.EOF {
|
||||
return Result{}, errors.New("invalid host export")
|
||||
}
|
||||
export = &candidate
|
||||
delete(raw, "hostExport")
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
@@ -505,10 +522,13 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
|
||||
if e = validateResult(result, env.WorkspaceID, operationName(command)); e != nil {
|
||||
return Result{}, e
|
||||
}
|
||||
if !resultExitMatches(result.Status, cr.ExitCode) {
|
||||
return Result{}, runErrOr(runErr, "workspace result status does not match child exit")
|
||||
}
|
||||
if runErr != nil {
|
||||
if result.Status == "blocked" && cr.ExitCode == 3 {
|
||||
} else if result.Status == "failed" && cr.ExitCode == 1 {
|
||||
} else {
|
||||
// Exit 1/3 are represented by the validated public result; callers still
|
||||
// render it and select the corresponding process exit code.
|
||||
if result.Status != "blocked" && result.Status != "failed" {
|
||||
return Result{}, runErr
|
||||
}
|
||||
}
|
||||
@@ -552,9 +572,6 @@ func candidateBoundToResult(x hostExport, result Result) bool {
|
||||
}
|
||||
|
||||
func publishCandidate(x *hostExport, result Result, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if x.MediaType != "application/yaml" && x.MediaType != "text/yaml" {
|
||||
return errors.New("invalid candidate export")
|
||||
}
|
||||
@@ -575,6 +592,9 @@ func publishCandidate(x *hostExport, result Result, path string) error {
|
||||
if result.RunID == "" || !runIDPattern.MatchString(result.RunID) {
|
||||
return errors.New("invalid candidate identity")
|
||||
}
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if e = safeio.WriteCanonicalExclusive(path, b, 0o600); e != nil {
|
||||
return errors.New("unsafe output file")
|
||||
}
|
||||
@@ -606,8 +626,89 @@ func validateResult(r Result, workspace, operation string) error {
|
||||
if r.Status != "blocked" && r.Code == CodeRegistryBootstrapRecoveryConflict {
|
||||
return errors.New("invalid workspace result")
|
||||
}
|
||||
if (r.Status == "succeeded" || r.Status == "unchanged" || r.Status == "dry_run") && r.Code != "ok" {
|
||||
return errors.New("invalid workspace result")
|
||||
}
|
||||
if r.Status == "failed" && (r.Code == "ok" || r.Code == "manual_review_required" || r.Code == "evidence_materialization_required" || r.Code == "preprocessing_conflict" || r.Code == "preprocessing_resume_mismatch") {
|
||||
return errors.New("invalid workspace result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resultExitMatches(status string, exitCode int) bool {
|
||||
switch status {
|
||||
case "succeeded", "unchanged", "dry_run":
|
||||
return exitCode == 0
|
||||
case "blocked":
|
||||
return exitCode == 3
|
||||
case "failed":
|
||||
return exitCode == 1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasDuplicateJSONFields(payload []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.UseNumber()
|
||||
if !scanJSONValue(decoder) {
|
||||
return true
|
||||
}
|
||||
_, err := decoder.Token()
|
||||
return err != io.EOF
|
||||
}
|
||||
|
||||
func scanJSONValue(decoder *json.Decoder) bool {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delimiter, isDelimiter := token.(json.Delim)
|
||||
if !isDelimiter {
|
||||
return true
|
||||
}
|
||||
switch delimiter {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for decoder.More() {
|
||||
keyToken, keyErr := decoder.Token()
|
||||
key, ok := keyToken.(string)
|
||||
if keyErr != nil || !ok || seen[key] || !scanJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err == nil
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if !scanJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, err = decoder.Token()
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sameJSONFieldSet(a, b map[string]json.RawMessage) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for key := range a {
|
||||
if _, ok := b[key]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
func DigestBytes(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) }
|
||||
func validStatus(v string) bool {
|
||||
switch v {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package workspaceops
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -132,3 +134,164 @@ func TestRunPublishesOnlyVerifiedCandidateExport(t *testing.T) {
|
||||
t.Fatalf("candidate = %q, %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validWorkspaceResult(status, code string) Result {
|
||||
return Result{SchemaVersion: 1, Status: status, Code: code, WorkspaceID: "psd", WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40), Operation: "evidence", CompletedStages: []string{}}
|
||||
}
|
||||
|
||||
func TestValidateIngressRejectsExplicitOmittedEvidenceZeroFields(t *testing.T) {
|
||||
command := EvidenceRequest{WorkspaceID: "psd"}
|
||||
expected, _, err := makeInput(command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, payload := range []string{
|
||||
`{"schemaVersion":1,"operation":"evidence","workspaceId":"psd","resume":""}`,
|
||||
`{"schemaVersion":1,"operation":"evidence","workspaceId":"psd","workspaceId":"psd"}`,
|
||||
`{"schemaVersion":1,"operation":"evidence","workspaceId":"psd","dryRun":false}`,
|
||||
`{"schemaVersion":1,"operation":"evidence","workspaceId":"psd","resume":null}`,
|
||||
} {
|
||||
if err := validateIngress([]byte(payload), expected); err == nil {
|
||||
t.Errorf("accepted non-derived envelope %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIngressRequiresExactDerivedFieldSetForOptionalInputs(t *testing.T) {
|
||||
command := SuggestFksRequest{WorkspaceID: "psd"}
|
||||
expected, _, err := makeInput(command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := `{"schemaVersion":1,"operation":"suggest-fks","workspaceId":"psd","sql":null,"assume":[]}`
|
||||
if err := validateIngress([]byte(payload), expected); err == nil {
|
||||
t.Fatal("accepted explicit null/empty optional fields omitted by command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCandidateValidatesInternalExportWithoutOutput(t *testing.T) {
|
||||
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}}
|
||||
for _, export := range []hostExport{
|
||||
{MediaType: "application/json", SHA256: digest, ContentBase64: base64.StdEncoding.EncodeToString(candidate)},
|
||||
{MediaType: "application/yaml", SHA256: digest, ContentBase64: base64.StdEncoding.EncodeToString(candidate)},
|
||||
} {
|
||||
if export.MediaType == "application/json" {
|
||||
if err := publishCandidate(&export, result, ""); err == nil {
|
||||
t.Fatal("accepted invalid media type without output")
|
||||
}
|
||||
} else if err := publishCandidate(&export, result, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResultBindsStatusCodeAndExit(t *testing.T) {
|
||||
cases := []struct {
|
||||
status, code string
|
||||
exit int
|
||||
valid bool
|
||||
}{
|
||||
{"succeeded", "ok", 0, true}, {"unchanged", "ok", 0, true}, {"dry_run", "ok", 0, true},
|
||||
{"blocked", "manual_review_required", 3, true}, {"failed", "workspace_not_found", 1, true},
|
||||
{"blocked", "manual_review_required", 0, false}, {"failed", "workspace_not_found", 0, false},
|
||||
{"succeeded", "workspace_not_found", 0, false}, {"failed", "ok", 1, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
r := validWorkspaceResult(tc.status, tc.code)
|
||||
err := validateResult(r, "psd", "evidence")
|
||||
got := err == nil && resultExitMatches(tc.status, tc.exit)
|
||||
if got != tc.valid {
|
||||
t.Errorf("%s/%s exit %d valid=%v err=%v", tc.status, tc.code, tc.exit, tc.valid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUnknownHostExportFieldWithoutOutput(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
candidate := []byte("candidates: []\n")
|
||||
digest := DigestBytes(candidate)
|
||||
response := `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"` + strings.Repeat("0", 40) + `","descriptorBlob":"` + strings.Repeat("a", 40) + `","operation":"suggest-fks","runId":"` + strings.Repeat("b", 32) + `","completedStages":[],"artifactIdentities":[{"kind":"fk-candidates","digest":"` + digest + `"}],"hostExport":{"mediaType":"application/yaml","sha256":"` + digest + `","contentBase64":"` + base64.StdEncoding.EncodeToString(candidate) + `","extra":1}}`
|
||||
fake := filepath.Join(d, "docker")
|
||||
if err := os.WriteFile(fake, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '"+response+"'\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := SuggestFksRequest{WorkspaceID: "psd"}
|
||||
if _, err := Run(context.Background(), config.Installation{ProjectDirectory: d}, compose.NewRunner(fake), cmd, nil); err == nil {
|
||||
t.Fatal("accepted unknown hostExport field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCandidateRejectsOversizedExportWithoutOutput(t *testing.T) {
|
||||
candidate := bytes.Repeat([]byte("y"), maxCandidate+1)
|
||||
digest := DigestBytes(candidate)
|
||||
x := hostExport{MediaType: "application/yaml", SHA256: digest, ContentBase64: base64.StdEncoding.EncodeToString(candidate)}
|
||||
r := validWorkspaceResult("succeeded", "ok")
|
||||
r.Operation, r.RunID = "suggest-fks", strings.Repeat("b", 32)
|
||||
if err := publishCandidate(&x, r, ""); err == nil {
|
||||
t.Fatal("accepted oversized export without output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAcceptsOnlyMatchingChildExitForStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, status, code string
|
||||
exit int
|
||||
wantErr bool
|
||||
}{
|
||||
{"blocked-exit3", "blocked", "manual_review_required", 3, false},
|
||||
{"blocked-exit0", "blocked", "manual_review_required", 0, true},
|
||||
{"failed-exit1", "failed", "workspace_not_found", 1, false},
|
||||
{"failed-exit0", "failed", "workspace_not_found", 0, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
response := fmt.Sprintf(`{"schemaVersion":1,"status":"%s","code":"%s","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"evidence","completedStages":[]}`,
|
||||
tc.status, tc.code, strings.Repeat("0", 40), strings.Repeat("a", 40))
|
||||
fake := filepath.Join(d, "docker")
|
||||
if err := os.WriteFile(fake, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '"+response+"'\nexit "+fmt.Sprint(tc.exit)+"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Run(context.Background(), config.Installation{ProjectDirectory: d}, compose.NewRunner(fake), EvidenceRequest{WorkspaceID: "psd"}, nil)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("error=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkspaceRejectsDuplicateMissingAndUnknownOptions(t *testing.T) {
|
||||
validResume := strings.Repeat("0", 32)
|
||||
for _, argv := range [][]string{
|
||||
{"workspace", "inspect", "--json", "--json", "--workspace", "psd"},
|
||||
{"workspace", "inspect", "--workspace", "psd", "--workspace", "psd"},
|
||||
{"workspace", "preprocess", "dwh", "--workspace", "psd", "--resume", validResume, "--resume", validResume},
|
||||
{"workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", "a", "--output", "b"},
|
||||
{"workspace", "inspect", "--json", "--unknown", "x", "--workspace", "psd"},
|
||||
{"workspace", "inspect", "--json"},
|
||||
} {
|
||||
if _, err := ParseWorkspaceCommand(argv); err == nil {
|
||||
t.Errorf("accepted invalid argv %v", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkspaceAcceptsAssumptionAndEnforcesLimits(t *testing.T) {
|
||||
if _, err := ParseWorkspaceCommand([]string{"workspace", "schema", "suggest-fks", "--workspace", "psd", "--assume", "orders.id=customers.id"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseWorkspaceCommand([]string{"workspace", "schema", "suggest-fks", "--workspace", "psd", "--assume", strings.Repeat("a", maxAssumptionBytes+1)}); err == nil {
|
||||
t.Fatal("accepted oversized assumption")
|
||||
}
|
||||
args := []string{"workspace", "schema", "suggest-fks", "--workspace", "psd"}
|
||||
for i := 0; i < 33; i++ {
|
||||
args = append(args, "--from-sql", fmt.Sprintf("schema-%d.sql", i))
|
||||
}
|
||||
if _, err := ParseWorkspaceCommand(args); err == nil {
|
||||
t.Fatal("accepted 33 SQL files")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user