diff --git a/tools/thothctl/internal/safeio/files_windows.go b/tools/thothctl/internal/safeio/files_windows.go index d7094ae8..810cb400 100644 --- a/tools/thothctl/internal/safeio/files_windows.go +++ b/tools/thothctl/internal/safeio/files_windows.go @@ -14,7 +14,14 @@ import ( "golang.org/x/sys/windows" ) -const windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE +const ( + // Retained input handles deny delete sharing while permitting ordinary reads + // and writes by trusted callers. + windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE + // Outputs are opened for exclusive publication: deny write/delete sharing, + // but permit the exact-identity read recheck below. + windowsOutputHandleShareMode uint32 = windows.FILE_SHARE_READ +) // ReadCanonicalRegular opens each component with FILE_FLAG_OPEN_REPARSE_POINT and rejects a // reparse point on the opened handle before opening the next component. Retained handles allow @@ -136,7 +143,7 @@ func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) err if err != nil { return ErrUnsafeFile } - h, err := windows.CreateFile(windows.StringToUTF16Ptr(filepath.Join(parent, filepath.Base(path))), windows.GENERIC_WRITE, 0, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + h, err := windows.CreateFile(windows.StringToUTF16Ptr(filepath.Join(parent, filepath.Base(path))), windows.GENERIC_WRITE, windowsOutputHandleShareMode, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) if err != nil { return ErrUnsafeFile } diff --git a/tools/thothctl/internal/safeio/files_windows_test.go b/tools/thothctl/internal/safeio/files_windows_test.go index 154fb25f..3f3cdfa8 100644 --- a/tools/thothctl/internal/safeio/files_windows_test.go +++ b/tools/thothctl/internal/safeio/files_windows_test.go @@ -3,19 +3,24 @@ package safeio import ( + "errors" "os" "path/filepath" "testing" + "github.com/aritmolab/thothii/tools/thothctl/internal/testsupport" "golang.org/x/sys/windows" ) const expectedWindowsRetainedHandleShareMode = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE +const expectedWindowsOutputHandleShareMode = windows.FILE_SHARE_READ // Keep this contract compile-enforced so Windows cross-test compilation catches a future // FILE_SHARE_DELETE regression even when the tests are compiled on a non-Windows host. var _ [windowsRetainedHandleShareMode - expectedWindowsRetainedHandleShareMode]struct{} var _ [expectedWindowsRetainedHandleShareMode - windowsRetainedHandleShareMode]struct{} +var _ [windowsOutputHandleShareMode - expectedWindowsOutputHandleShareMode]struct{} +var _ [expectedWindowsOutputHandleShareMode - windowsOutputHandleShareMode]struct{} func TestOpenWindowsComponentBlocksMutationWhileHandleIsRetained(t *testing.T) { t.Run("parent rename", func(t *testing.T) { @@ -98,3 +103,41 @@ func TestWriteCanonicalExclusiveCreatesProtectedOwnerOnlyDACL(t *testing.T) { t.Fatalf("output DACL = %#v, err=%v; want one owner ACE", acl, err) } } + +func TestWriteCanonicalExclusiveAllowsOwnerOnlyWriteAndIdentityRecheck(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "candidate.yaml") + if err := writeCanonicalExclusive(path, []byte("candidates: []\n"), 0o600); err != nil { + t.Fatalf("owner-only output write/recheck failed: %v", err) + } + contents, err := os.ReadFile(path) + if err != nil || string(contents) != "candidates: []\n" { + t.Fatalf("output = %q, err=%v", contents, err) + } + if err := writeCanonicalExclusive(path, []byte("replacement\n"), 0o600); !errors.Is(err, ErrUnsafeFile) { + t.Fatalf("existing output replacement = %v, want ErrUnsafeFile", err) + } +} + +func TestWriteCanonicalExclusiveRejectsReparseParent(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + realParent := filepath.Join(root, "real-parent") + if err := os.Mkdir(realParent, 0o700); err != nil { + t.Fatal(err) + } + linkedParent := filepath.Join(root, "linked-parent") + testsupport.SymlinkOrSkip(t, realParent, linkedParent) + path := filepath.Join(linkedParent, "candidate.yaml") + if err := writeCanonicalExclusive(path, []byte("unsafe\n"), 0o600); !errors.Is(err, ErrUnsafeFile) { + t.Fatalf("reparse parent output = %v, want ErrUnsafeFile", err) + } + if _, err := os.Stat(filepath.Join(realParent, "candidate.yaml")); !os.IsNotExist(err) { + t.Fatalf("reparse parent write created target: stat err=%v", err) + } +} diff --git a/tools/thothctl/internal/workspaceops/operations_test.go b/tools/thothctl/internal/workspaceops/operations_test.go index dcdc5643..4d3a35f7 100644 --- a/tools/thothctl/internal/workspaceops/operations_test.go +++ b/tools/thothctl/internal/workspaceops/operations_test.go @@ -13,6 +13,7 @@ import ( "context" "github.com/aritmolab/thothii/tools/thothctl/internal/compose" "github.com/aritmolab/thothii/tools/thothctl/internal/config" + "github.com/aritmolab/thothii/tools/thothctl/internal/safeio" ) func TestParseWorkspaceCommands(t *testing.T) { @@ -349,3 +350,201 @@ func TestMakeInputAcceptsMaximumAnnotationWithReviewDigestPair(t *testing.T) { t.Fatalf("validateIngress() = %v", err) } } + +func TestWorkspaceIngressAcceptsExactCountsAndByteBounds(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + paths := make([]string, 0, 32) + for i := 0; i < 32; i++ { + path := filepath.Join(root, fmt.Sprintf("schema-%02d.sql", i)) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + assumptions := make([]string, 0, maxAssumptions) + for i := 0; i < maxAssumptions; i++ { + assumptions = append(assumptions, fmt.Sprintf("column_%d=table_%d", i, i)) + } + command := SuggestFksRequest{WorkspaceID: "psd", FromSQL: paths, Assume: assumptions} + for _, path := range paths { + if b, e := safeio.ReadCanonicalUTF8(path, maxSQLFile); e != nil { + t.Fatalf("direct read %s: %v", path, e) + } else if len(b) != 1 { + t.Fatalf("direct read length=%d", len(b)) + } + } + env, payload, err := makeInput(command) + if err != nil { + t.Fatalf("exact file/assumption counts rejected: %v", err) + } + if len(env.SQL) != 32 || len(env.Assume) != maxAssumptions { + t.Fatalf("derived envelope counts = sql %d assumptions %d", len(env.SQL), len(env.Assume)) + } + if err := validateIngress([]byte(payload), env); err != nil { + t.Fatalf("exact-bound envelope rejected: %v", err) + } +} + +func TestWorkspaceIngressRejectsSQLAggregateOverLimit(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + paths := make([]string, 0, 17) + for i := 0; i < 17; i++ { + path := filepath.Join(root, fmt.Sprintf("schema-%02d.sql", i)) + if err := os.WriteFile(path, bytes.Repeat([]byte("x"), maxSQLFile), 0o600); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + command := SuggestFksRequest{WorkspaceID: "psd", FromSQL: paths[:16]} + if _, _, err := makeInput(command); err != nil { + t.Fatalf("exact SQL aggregate rejected: %v", err) + } + command.FromSQL = paths + if _, _, err := makeInput(command); err == nil { + t.Fatal("accepted SQL aggregate over 16 MiB") + } +} + +func TestValidateIngressRejectsTrailingAndUnknownFields(t *testing.T) { + command := EvidenceRequest{WorkspaceID: "psd", DryRun: true} + expected, payload, err := makeInput(command) + if err != nil { + t.Fatal(err) + } + if err := validateIngress([]byte(payload), expected); err != nil { + t.Fatalf("generated envelope rejected: %v", err) + } + for _, invalid := range []string{ + payload + " {}", + strings.TrimSuffix(payload, "}") + `,"unknown":1}`, + } { + if err := validateIngress([]byte(invalid), expected); err == nil { + t.Errorf("accepted invalid ingress envelope %q", invalid) + } + } +} + +func TestValidateResultRejectsIdentitySchemaAndUnknownValues(t *testing.T) { + base := validWorkspaceResult("succeeded", "ok") + cases := []Result{ + base, + func() Result { r := base; r.SchemaVersion = 2; return r }(), + func() Result { r := base; r.WorkspaceID = "other"; return r }(), + func() Result { r := base; r.Operation = "run"; return r }(), + func() Result { r := base; r.WorkspaceRevision = "short"; return r }(), + func() Result { r := base; r.DescriptorBlob = "short"; return r }(), + func() Result { r := base; r.RunID = "not-a-run"; return r }(), + func() Result { r := base; r.ChildRuns = map[string]string{"child": "not-a-run"}; return r }(), + func() Result { + r := base + r.ArtifactIdentities = []ArtifactIdentity{{Kind: "fk", Digest: "not-a-digest"}} + return r + }(), + func() Result { r := base; r.CompletedStages = nil; return r }(), + } + for i, result := range cases { + err := validateResult(result, "psd", "evidence") + if (i == 0 && err != nil) || (i > 0 && err == nil) { + t.Errorf("case %d validateResult() = %v", i, err) + } + } +} + +func TestPublishCandidateRejectsInvalidEncodingDigestUTF8AndYAML(t *testing.T) { + result := validWorkspaceResult("succeeded", "ok") + result.Operation = "suggest-fks" + result.RunID = strings.Repeat("b", 32) + valid := []byte("candidates: []\n") + validDigest := DigestBytes(valid) + result.ArtifactIdentities = []ArtifactIdentity{{Kind: "fk-candidates", Digest: validDigest}} + cases := []hostExport{ + {MediaType: "application/yaml", SHA256: validDigest, ContentBase64: "%%%"}, + {MediaType: "application/yaml", SHA256: DigestBytes([]byte("other")), ContentBase64: base64.StdEncoding.EncodeToString(valid)}, + {MediaType: "application/yaml", SHA256: validDigest, ContentBase64: base64.StdEncoding.EncodeToString([]byte{0xff})}, + {MediaType: "application/yaml", SHA256: DigestBytes([]byte("not yaml")), ContentBase64: base64.StdEncoding.EncodeToString([]byte("["))}, + } + for i, candidate := range cases { + if err := publishCandidate(&candidate, result, ""); err == nil { + t.Errorf("case %d accepted invalid candidate export", i) + } + } +} + +func TestRunRejectsUnsafeInputsBeforeComposeInvocation(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + realSQL := filepath.Join(root, "real.sql") + if err := os.WriteFile(realSQL, []byte("select 1"), 0o600); err != nil { + t.Fatal(err) + } + unsafeSQL := filepath.Join(root, "linked.sql") + if err := os.Symlink(realSQL, unsafeSQL); err != nil { + t.Fatal(err) + } + marker := filepath.Join(root, "invoked") + fake := filepath.Join(root, "docker") + if err := os.WriteFile(fake, []byte("#!/bin/sh\ntouch '"+marker+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + command := SuggestFksRequest{WorkspaceID: "psd", FromSQL: []string{unsafeSQL}} + if _, err := Run(context.Background(), config.Installation{ProjectDirectory: root}, compose.NewRunner(fake), command, nil); err == nil { + t.Fatal("accepted unsafe SQL input") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("Compose was invoked for unsafe input: stat err=%v", err) + } +} + +func TestMakeInputRejectsOversizedOrInvalidAnnotation(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "annotations.md") + if err := os.WriteFile(path, bytes.Repeat([]byte("a"), maxAnnotations+1), 0o600); err != nil { + t.Fatal(err) + } + command := CheckSchemaRequest{WorkspaceID: "psd", Resume: strings.Repeat("0", 32), Annotations: path, ReviewedCandidates: DigestBytes([]byte("reviewed"))} + if _, _, err := makeInput(command); err == nil { + t.Fatal("accepted annotation over 16 MiB") + } + if err := os.WriteFile(path, []byte{0xff}, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := makeInput(command); err == nil { + t.Fatal("accepted non-UTF-8 annotation") + } +} + +func TestRunRejectsTrailingOrUnknownResultEnvelope(t *testing.T) { + for name, suffix := range map[string]string{ + "trailing": "} {}", + "unknown": `,"unknown":1`, + } { + t.Run(name, func(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + response := `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"` + strings.Repeat("0", 40) + `","descriptorBlob":"` + strings.Repeat("a", 40) + `","operation":"evidence","completedStages":[]` + suffix + if name == "unknown" { + response += "}" + } + fake := filepath.Join(root, "docker") + if err := os.WriteFile(fake, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '"+response+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + if _, err := Run(context.Background(), config.Installation{ProjectDirectory: root}, compose.NewRunner(fake), EvidenceRequest{WorkspaceID: "psd"}, nil); err == nil { + t.Fatal("accepted malformed result envelope") + } + }) + } +}