fix(translator): strip unsupported unicode property escape patterns from tool schemas
Unified · Split
| @@ -1,6 +1,9 @@ | |||
| 1 | package helps | 1 | package helps |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "io" | ||
| 4 | "math/big" | 7 | "math/big" |
| 5 | "strconv" | 8 | "strconv" |
| 6 | "strings" | 9 | "strings" |
| @@ -8,6 +11,8 @@ import ( · 1 unmodified line | |||
| 8 | log "github.com/sirupsen/logrus" | 11 | log "github.com/sirupsen/logrus" |
| 9 | "github.com/tidwall/gjson" | 12 | "github.com/tidwall/gjson" |
| 10 | "github.com/tidwall/sjson" | 13 | "github.com/tidwall/sjson" |
| 14 | |||
| 15 | "github.com/router-for-me/CLIProxyAPI/v7/internal/util" | ||
| 11 | ) | 16 | ) |
| 12 | 17 | ||
| 13 | const ( | 18 | const ( |
| @@ -89,7 +94,7 @@ func normalizeCodexTool(tool gjson.Result) ([]byte, bool) { · 75 unmodified lines | |||
| 89 | return nil, false | 94 | return nil, false |
| 90 | } | 95 | } |
| 91 | 96 | ||
| 92 | log.Debugf("codex: simplified complex schema unions for tool %s to avoid upstream abort", tool.Get("name").String()) | 97 | log.Debugf("codex: normalized schema for tool %s to avoid upstream abort", tool.Get("name").String()) |
| 93 | return updatedTool, true | 98 | return updatedTool, true |
| 94 | } | 99 | } |
| 95 | 100 | ||
| @@ -97,6 +102,12 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 1 unmodified line | |||
| 97 | rawParams := []byte(params.Raw) | 102 | rawParams := []byte(params.Raw) |
| 98 | changed := false | 103 | changed := false |
| 99 | 104 | ||
| 105 | if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(rawParams); patternChanged { | ||
| 106 | rawParams = sanitizedParams | ||
| 107 | changed = true | ||
| 108 | params = gjson.ParseBytes(rawParams) | ||
| 109 | } | ||
| 110 | |||
| 100 | properties := params.Get("properties") | 111 | properties := params.Get("properties") |
| 101 | if properties.Exists() && properties.IsObject() { | 112 | if properties.Exists() && properties.IsObject() { |
| 102 | for propName, propVal := range properties.Map() { | 113 | for propName, propVal := range properties.Map() { |
| @@ -115,6 +126,98 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) { · 12 unmodified lines | |||
| 115 | return rawParams, changed | 126 | return rawParams, changed |
| 116 | } | 127 | } |
| 117 | 128 | ||
| 129 | // stripIncompatiblePatternsFromJSON recursively removes pattern attributes containing | ||
| 130 | // unsupported Unicode property escapes (\p{...} / \P{...}) from parameter schemas. | ||
| 131 | // It is schema-aware: only subschemas under known JSON Schema keyword locations are visited, | ||
| 132 | // preventing accidental deletion of 'pattern' keys inside user data (e.g. description, default, enum). | ||
| 133 | func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) { | ||
| 134 | rawStr := string(raw) | ||
| 135 | if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) { | ||
| 136 | return raw, false | ||
| 137 | } | ||
| 138 | var root any | ||
| 139 | dec := json.NewDecoder(bytes.NewReader(raw)) | ||
| 140 | dec.UseNumber() | ||
| 141 | if err := dec.Decode(&root); err != nil || root == nil { | ||
| 142 | return raw, false | ||
| 143 | } | ||
| 144 | // Verify no trailing garbage | ||
| 145 | var dummy any | ||
| 146 | if err := dec.Decode(&dummy); err != io.EOF { | ||
| 147 | return raw, false | ||
| 148 | } | ||
| 149 | if !stripIncompatiblePatterns(root) { | ||
| 150 | return raw, false | ||
| 151 | } | ||
| 152 | var buf bytes.Buffer | ||
| 153 | enc := json.NewEncoder(&buf) | ||
| 154 | enc.SetEscapeHTML(false) | ||
| 155 | if err := enc.Encode(root); err != nil { | ||
| 156 | return raw, false | ||
| 157 | } | ||
| 158 | return bytes.TrimSpace(buf.Bytes()), true | ||
| 159 | } | ||
| 160 | |||
| 161 | func stripIncompatiblePatterns(v any) bool { | ||
| 162 | changed := false | ||
| 163 | switch schema := v.(type) { | ||
| 164 | case map[string]any: | ||
| 165 | if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) { | ||
| 166 | delete(schema, "pattern") | ||
| 167 | changed = true | ||
| 168 | } | ||
| 169 | |||
| 170 | // Inspect regex keys under patternProperties | ||
| 171 | if patternProps, ok := schema["patternProperties"].(map[string]any); ok { | ||
| 172 | for patternKey, subSchema := range patternProps { | ||
| 173 | if util.HasUnsupportedUnicodePropertyEscape(patternKey) { | ||
| 174 | delete(patternProps, patternKey) | ||
| 175 | changed = true | ||
| 176 | } else if stripIncompatiblePatterns(subSchema) { | ||
| 177 | changed = true | ||
| 178 | } | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | for _, mapKey := range util.SchemaMapKeywords { | ||
| 183 | if mapKey == "patternProperties" { | ||
| 184 | continue | ||
| 185 | } | ||
| 186 | if subMap, ok := schema[mapKey].(map[string]any); ok { | ||
| 187 | for _, subSchema := range subMap { | ||
| 188 | if stripIncompatiblePatterns(subSchema) { | ||
| 189 | changed = true | ||
| 190 | } | ||
| 191 | } | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | for _, valKey := range util.SchemaValueKeywords { | ||
| 196 | if val, exists := schema[valKey]; exists { | ||
| 197 | switch sub := val.(type) { | ||
| 198 | case map[string]any: | ||
| 199 | if stripIncompatiblePatterns(sub) { | ||
| 200 | changed = true | ||
| 201 | } | ||
| 202 | case []any: | ||
| 203 | for _, item := range sub { | ||
| 204 | if stripIncompatiblePatterns(item) { | ||
| 205 | changed = true | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
| 209 | } | ||
| 210 | } | ||
| 211 | case []any: | ||
| 212 | for _, item := range schema { | ||
| 213 | if stripIncompatiblePatterns(item) { | ||
| 214 | changed = true | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return changed | ||
| 219 | } | ||
| 220 | |||
| 118 | func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) { | 221 | func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) { |
| 119 | if !prop.IsObject() { | 222 | if !prop.IsObject() { |
| 120 | return nil, false | 223 | return nil, false |
| @@ -491,3 +491,286 @@ func TestNormalizeCodexToolSchemas_NamespaceToolSimplified(t *testing.T) { · 490 unmodified lines | |||
| 491 | t.Fatalf("expected action.enum with 8 items") | 491 | t.Fatalf("expected action.enum with 8 items") |
| 492 | } | 492 | } |
| 493 | } | 493 | } |
| 494 | |||
| 495 | func TestNormalizeCodexToolSchemas_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) { | ||
| 496 | input := []byte(`{ | ||
| 497 | "model": "gpt-5.6", | ||
| 498 | "tools": [{ | ||
| 499 | "type": "function", | ||
| 500 | "name": "Artifact", | ||
| 501 | "parameters": { | ||
| 502 | "type": "object", | ||
| 503 | "properties": { | ||
| 504 | "field": { | ||
| 505 | "type": "string", | ||
| 506 | "description": "field to edit", | ||
| 507 | "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$" | ||
| 508 | }, | ||
| 509 | "asset_id": { | ||
| 510 | "type": "string", | ||
| 511 | "pattern": "^[0-9a-f]{32}$" | ||
| 512 | } | ||
| 513 | }, | ||
| 514 | "required": ["field"] | ||
| 515 | } | ||
| 516 | }] | ||
| 517 | }`) | ||
| 518 | |||
| 519 | out := NormalizeCodexToolSchemas(input) | ||
| 520 | |||
| 521 | tool := gjson.GetBytes(out, "tools.0") | ||
| 522 | params := tool.Get("parameters") | ||
| 523 | |||
| 524 | // Unsupported pattern must be removed | ||
| 525 | if params.Get("properties.field.pattern").Exists() { | ||
| 526 | t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw) | ||
| 527 | } | ||
| 528 | if got := params.Get("properties.field.type").String(); got != "string" { | ||
| 529 | t.Errorf("expected properties.field.type == 'string', got %q", got) | ||
| 530 | } | ||
| 531 | |||
| 532 | // Valid pattern must be preserved | ||
| 533 | if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" { | ||
| 534 | t.Errorf("expected properties.asset_id.pattern preserved, got %q", got) | ||
| 535 | } | ||
| 536 | |||
| 537 | // Idempotence test | ||
| 538 | outAgain := NormalizeCodexToolSchemas(out) | ||
| 539 | if string(outAgain) != string(out) { | ||
| 540 | t.Errorf("expected NormalizeCodexToolSchemas to be idempotent") | ||
| 541 | } | ||
| 542 | } | ||
| 543 | |||
| 544 | func TestNormalizeCodexToolSchemas_PreservesNonSchemaPatternKeys(t *testing.T) { | ||
| 545 | // A property whose default, enum, or description metadata contains a nested object | ||
| 546 | // with a 'pattern' key must NOT be mutated, because it is user data, not a schema. | ||
| 547 | input := []byte(`{ | ||
| 548 | "model": "gpt-5.6", | ||
| 549 | "tools": [{ | ||
| 550 | "type": "function", | ||
| 551 | "name": "config_tool", | ||
| 552 | "parameters": { | ||
| 553 | "type": "object", | ||
| 554 | "properties": { | ||
| 555 | "regex_config": { | ||
| 556 | "type": "object", | ||
| 557 | "default": { | ||
| 558 | "pattern": "\\p{L}+" | ||
| 559 | }, | ||
| 560 | "enum": [ | ||
| 561 | {"pattern": "\\p{N}+"} | ||
| 562 | ] | ||
| 563 | }, | ||
| 564 | "real_schema": { | ||
| 565 | "type": "string", | ||
| 566 | "pattern": "\\p{L}+" | ||
| 567 | } | ||
| 568 | } | ||
| 569 | } | ||
| 570 | }] | ||
| 571 | }`) | ||
| 572 | |||
| 573 | out := NormalizeCodexToolSchemas(input) | ||
| 574 | |||
| 575 | tool := gjson.GetBytes(out, "tools.0") | ||
| 576 | params := tool.Get("parameters") | ||
| 577 | |||
| 578 | // Real schema pattern must be removed | ||
| 579 | if params.Get("properties.real_schema.pattern").Exists() { | ||
| 580 | t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw) | ||
| 581 | } | ||
| 582 | |||
| 583 | // User data under default and enum must be PRESERVED | ||
| 584 | if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` { | ||
| 585 | t.Errorf("expected default.pattern preserved, got %q", got) | ||
| 586 | } | ||
| 587 | if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` { | ||
| 588 | t.Errorf("expected enum.0.pattern preserved, got %q", got) | ||
| 589 | } | ||
| 590 | } | ||
| 591 | |||
| 592 | func TestNormalizeCodexToolSchemas_CoversAllSchemaKeywordLocations(t *testing.T) { | ||
| 593 | input := []byte(`{ | ||
| 594 | "model": "gpt-5.6", | ||
| 595 | "tools": [{ | ||
| 596 | "type": "function", | ||
| 597 | "name": "deep_tool", | ||
| 598 | "parameters": { | ||
| 599 | "type": "object", | ||
| 600 | "$defs": { | ||
| 601 | "custom_type": { | ||
| 602 | "type": "string", | ||
| 603 | "pattern": "\\p{L}+" | ||
| 604 | } | ||
| 605 | }, | ||
| 606 | "additionalProperties": { | ||
| 607 | "type": "string", | ||
| 608 | "pattern": "\\p{N}+" | ||
| 609 | }, | ||
| 610 | "patternProperties": { | ||
| 611 | "^s_": { | ||
| 612 | "type": "string", | ||
| 613 | "pattern": "\\p{M}+" | ||
| 614 | } | ||
| 615 | }, | ||
| 616 | "if": { | ||
| 617 | "properties": { | ||
| 618 | "flag": { | ||
| 619 | "type": "string", | ||
| 620 | "pattern": "\\p{P}+" | ||
| 621 | } | ||
| 622 | } | ||
| 623 | }, | ||
| 624 | "then": { | ||
| 625 | "properties": { | ||
| 626 | "val": { | ||
| 627 | "type": "string", | ||
| 628 | "pattern": "\\p{S}+" | ||
| 629 | } | ||
| 630 | } | ||
| 631 | }, | ||
| 632 | "else": { | ||
| 633 | "properties": { | ||
| 634 | "other": { | ||
| 635 | "type": "string", | ||
| 636 | "pattern": "\\p{Z}+" | ||
| 637 | } | ||
| 638 | } | ||
| 639 | } | ||
| 640 | } | ||
| 641 | }] | ||
| 642 | }`) | ||
| 643 | |||
| 644 | out := NormalizeCodexToolSchemas(input) | ||
| 645 | |||
| 646 | tool := gjson.GetBytes(out, "tools.0") | ||
| 647 | params := tool.Get("parameters") | ||
| 648 | |||
| 649 | // All subschemas in schema-aware locations must have their incompatible patterns stripped | ||
| 650 | if params.Get("$defs.custom_type.pattern").Exists() { | ||
| 651 | t.Errorf("expected $defs.custom_type.pattern to be removed") | ||
| 652 | } | ||
| 653 | if params.Get("additionalProperties.pattern").Exists() { | ||
| 654 | t.Errorf("expected additionalProperties.pattern to be removed") | ||
| 655 | } | ||
| 656 | if params.Get("patternProperties.^s_.pattern").Exists() { | ||
| 657 | t.Errorf("expected patternProperties.^s_.pattern to be removed") | ||
| 658 | } | ||
| 659 | if params.Get("if.properties.flag.pattern").Exists() { | ||
| 660 | t.Errorf("expected if.properties.flag.pattern to be removed") | ||
| 661 | } | ||
| 662 | if params.Get("then.properties.val.pattern").Exists() { | ||
| 663 | t.Errorf("expected then.properties.val.pattern to be removed") | ||
| 664 | } | ||
| 665 | if params.Get("else.properties.other.pattern").Exists() { | ||
| 666 | t.Errorf("expected else.properties.other.pattern to be removed") | ||
| 667 | } | ||
| 668 | |||
| 669 | // Subschema types must be preserved | ||
| 670 | if got := params.Get("$defs.custom_type.type").String(); got != "string" { | ||
| 671 | t.Errorf("expected $defs.custom_type.type == 'string', got %q", got) | ||
| 672 | } | ||
| 673 | } | ||
| 674 | |||
| 675 | func TestNormalizeCodexToolSchemas_MalformedOrEmptyParametersFallback(t *testing.T) { | ||
| 676 | // Malformed JSON, non-object parameters, null, and empty payloads must not panic | ||
| 677 | cases := [][]byte{ | ||
| 678 | []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":null}]}`), | ||
| 679 | []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":"not_an_object"}]}`), | ||
| 680 | []byte(`{"model":"gpt-5.6","tools":[{"type":"function","name":"t","parameters":{"type":"object"}}]}`), | ||
| 681 | []byte(`{"model":"gpt-5.6","tools":[]}`), | ||
| 682 | []byte(`{"model":"gpt-5.6"}`), | ||
| 683 | } | ||
| 684 | |||
| 685 | for i, c := range cases { | ||
| 686 | out := NormalizeCodexToolSchemas(c) | ||
| 687 | if len(out) == 0 { | ||
| 688 | t.Errorf("case %d: unexpected empty output", i) | ||
| 689 | } | ||
| 690 | } | ||
| 691 | } | ||
| 692 | |||
| 693 | func TestNormalizeCodexToolSchemas_JSONUnicodeEscapeBypassPrevention(t *testing.T) { | ||
| 694 | // Patterns encoded using JSON Unicode escapes (e.g. \u005c for '\' or \u0070 for 'p') | ||
| 695 | // decode to \p{...} / \P{...} and must not be skipped by the fast-path check. | ||
| 696 | input := []byte(`{ | ||
| 697 | "model": "gpt-5.6", | ||
| 698 | "tools": [{ | ||
| 699 | "type": "function", | ||
| 700 | "name": "escape_bypass_tool", | ||
| 701 | "parameters": { | ||
| 702 | "type": "object", | ||
| 703 | "properties": { | ||
| 704 | "p1": { | ||
| 705 | "type": "string", | ||
| 706 | "pattern": "\u005c\u0070{L}+" | ||
| 707 | }, | ||
| 708 | "p2": { | ||
| 709 | "type": "string", | ||
| 710 | "pattern": "\u005cp{Cc}" | ||
| 711 | }, | ||
| 712 | "p3": { | ||
| 713 | "type": "string", | ||
| 714 | "pattern": "\u005c\u0050{N}+" | ||
| 715 | }, | ||
| 716 | "valid": { | ||
| 717 | "type": "string", | ||
| 718 | "pattern": "^[0-9a-f]{32}$" | ||
| 719 | } | ||
| 720 | } | ||
| 721 | } | ||
| 722 | }] | ||
| 723 | }`) | ||
| 724 | |||
| 725 | out := NormalizeCodexToolSchemas(input) | ||
| 726 | tool := gjson.GetBytes(out, "tools.0") | ||
| 727 | params := tool.Get("parameters") | ||
| 728 | |||
| 729 | if params.Get("properties.p1.pattern").Exists() { | ||
| 730 | t.Errorf("expected properties.p1.pattern (\\u0070) to be removed, got: %s", params.Get("properties.p1.pattern").Raw) | ||
| 731 | } | ||
| 732 | if params.Get("properties.p2.pattern").Exists() { | ||
| 733 | t.Errorf("expected properties.p2.pattern (\\u005c) to be removed, got: %s", params.Get("properties.p2.pattern").Raw) | ||
| 734 | } | ||
| 735 | if params.Get("properties.p3.pattern").Exists() { | ||
| 736 | t.Errorf("expected properties.p3.pattern (\\u0050) to be removed, got: %s", params.Get("properties.p3.pattern").Raw) | ||
| 737 | } | ||
| 738 | if got := params.Get("properties.valid.pattern").String(); got != "^[0-9a-f]{32}$" { | ||
| 739 | t.Errorf("expected valid.pattern to be preserved, got %q", got) | ||
| 740 | } | ||
| 741 | } | ||
| 742 | |||
| 743 | func TestNormalizeCodexToolSchemas_PatternPropertiesKeySanitization(t *testing.T) { | ||
| 744 | input := []byte(`{ | ||
| 745 | "model": "gpt-5.6", | ||
| 746 | "tools": [{ | ||
| 747 | "type": "function", | ||
| 748 | "name": "pattern_props_tool", | ||
| 749 | "parameters": { | ||
| 750 | "type": "object", | ||
| 751 | "patternProperties": { | ||
| 752 | "^\\\\p{L}+$": { | ||
| 753 | "type": "string" | ||
| 754 | }, | ||
| 755 | "^[a-z]+$": { | ||
| 756 | "type": "number" | ||
| 757 | } | ||
| 758 | } | ||
| 759 | } | ||
| 760 | }] | ||
| 761 | }`) | ||
| 762 | |||
| 763 | out := NormalizeCodexToolSchemas(input) | ||
| 764 | tool := gjson.GetBytes(out, "tools.0") | ||
| 765 | params := tool.Get("parameters") | ||
| 766 | |||
| 767 | // Key with \p{L}+ must be removed | ||
| 768 | patternProps := params.Get("patternProperties").Map() | ||
| 769 | if _, exists := patternProps[`^\p{L}+$`]; exists { | ||
| 770 | t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw) | ||
| 771 | } | ||
| 772 | // Safe key must be preserved | ||
| 773 | if _, exists := patternProps[`^[a-z]+$`]; !exists { | ||
| 774 | t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw) | ||
| 775 | } | ||
| 776 | } | ||
| @@ -665,8 +665,9 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string · 664 unmodified lines | |||
| 665 | return m | 665 | return m |
| 666 | } | 666 | } |
| 667 | 667 | ||
| 668 | // normalizeToolParameters ensures object schemas contain at least an empty properties map | 668 | // normalizeToolParameters ensures object schemas contain at least an empty properties map, |
| 669 | // and strips dialect keywords ($schema, $id) from schema objects. | 669 | // strips dialect keywords ($schema, $id), and drops regex patterns containing unsupported |
| 670 | // Unicode property escapes (\p{...} / \P{...}) that cause upstream schema validation failures. | ||
| 670 | func normalizeToolParameters(raw string) string { | 671 | func normalizeToolParameters(raw string) string { |
| 671 | raw = strings.TrimSpace(raw) | 672 | raw = strings.TrimSpace(raw) |
| 672 | if raw == "" || raw == "null" || !gjson.Valid(raw) { | 673 | if raw == "" || raw == "null" || !gjson.Valid(raw) { |
| @@ -721,8 +722,25 @@ func stripDialectKeywordsFromSchema(v any) { · 48 unmodified lines | |||
| 721 | case map[string]any: | 722 | case map[string]any: |
| 722 | delete(schema, "$schema") | 723 | delete(schema, "$schema") |
| 723 | delete(schema, "$id") | 724 | delete(schema, "$id") |
| 725 | if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) { | ||
| 726 | delete(schema, "pattern") | ||
| 727 | } | ||
| 728 | |||
| 729 | // Inspect regex keys under patternProperties | ||
| 730 | if patternProps, ok := schema["patternProperties"].(map[string]any); ok { | ||
| 731 | for patternKey, subSchema := range patternProps { | ||
| 732 | if util.HasUnsupportedUnicodePropertyEscape(patternKey) { | ||
| 733 | delete(patternProps, patternKey) | ||
| 734 | } else { | ||
| 735 | stripDialectKeywordsFromSchema(subSchema) | ||
| 736 | } | ||
| 737 | } | ||
| 738 | } | ||
| 724 | 739 | ||
| 725 | for _, mapKey := range codexSchemaMapKeywords { | 740 | for _, mapKey := range codexSchemaMapKeywords { |
| 741 | if mapKey == "patternProperties" { | ||
| 742 | continue | ||
| 743 | } | ||
| 726 | if subMap, ok := schema[mapKey].(map[string]any); ok { | 744 | if subMap, ok := schema[mapKey].(map[string]any); ok { |
| 727 | for _, subSchema := range subMap { | 745 | for _, subSchema := range subMap { |
| 728 | stripDialectKeywordsFromSchema(subSchema) | 746 | stripDialectKeywordsFromSchema(subSchema) |
| @@ -749,36 +767,12 @@ func stripDialectKeywordsFromSchema(v any) { · 20 unmodified lines | |||
| 749 | } | 767 | } |
| 750 | } | 768 | } |
| 751 | 769 | ||
| 752 | // codexSchemaMapKeywords holds JSON Schema keywords whose values are maps of | 770 | // codexSchemaMapKeywords and codexSchemaValueKeywords reference the unified JSON Schema keywords |
| 753 | // subschemas; codexSchemaValueKeywords holds keywords with a single nested | 771 | // declared in internal/util. |
| 754 | // schema or a list of schemas. | 772 | var ( |
| 755 | var codexSchemaMapKeywords = [...]string{ | 773 | codexSchemaMapKeywords = util.SchemaMapKeywords |
| 756 | "properties", | 774 | codexSchemaValueKeywords = util.SchemaValueKeywords |
| 757 | "$defs", | 775 | ) |
| 758 | "definitions", | ||
| 759 | "patternProperties", | ||
| 760 | "dependentSchemas", | ||
| 761 | "dependencies", | ||
| 762 | } | ||
| 763 | |||
| 764 | var codexSchemaValueKeywords = [...]string{ | ||
| 765 | "items", | ||
| 766 | "prefixItems", | ||
| 767 | "contains", | ||
| 768 | "additionalProperties", | ||
| 769 | "propertyNames", | ||
| 770 | "unevaluatedProperties", | ||
| 771 | "unevaluatedItems", | ||
| 772 | "additionalItems", | ||
| 773 | "contentSchema", | ||
| 774 | "anyOf", | ||
| 775 | "oneOf", | ||
| 776 | "allOf", | ||
| 777 | "not", | ||
| 778 | "if", | ||
| 779 | "then", | ||
| 780 | "else", | ||
| 781 | } | ||
| 782 | 776 | ||
| 783 | // codexSchemaMissesRequired reports whether a JSON Schema has any declared | 777 | // codexSchemaMissesRequired reports whether a JSON Schema has any declared |
| 784 | // property missing from its sibling required list (recursively). OpenAI | 778 | // property missing from its sibling required list (recursively). OpenAI |
| @@ -1124,3 +1124,141 @@ func TestConvertClaudeRequestToCodex_StripsNestedToolSchemaMeta(t *testing.T) { · 1123 unmodified lines | |||
| 1124 | t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw) | 1124 | t.Errorf("expected parameters.$defs.hint.$id to be removed, got %v", params.Get("$defs.hint.$id").Raw) |
| 1125 | } | 1125 | } |
| 1126 | } | 1126 | } |
| 1127 | |||
| 1128 | func TestConvertClaudeRequestToCodex_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) { | ||
| 1129 | inputJSON := `{ | ||
| 1130 | "model": "gpt-5.6", | ||
| 1131 | "messages": [{"role": "user", "content": "hello"}], | ||
| 1132 | "tools": [{ | ||
| 1133 | "name": "Artifact", | ||
| 1134 | "description": "Render an HTML file to an Artifact", | ||
| 1135 | "input_schema": { | ||
| 1136 | "type": "object", | ||
| 1137 | "properties": { | ||
| 1138 | "field": { | ||
| 1139 | "type": "string", | ||
| 1140 | "description": "field to replace", | ||
| 1141 | "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$" | ||
| 1142 | }, | ||
| 1143 | "asset_id": { | ||
| 1144 | "type": "string", | ||
| 1145 | "pattern": "^[0-9a-f]{32}$" | ||
| 1146 | }, | ||
| 1147 | "lookahead_safe": { | ||
| 1148 | "type": "string", | ||
| 1149 | "pattern": "^(?!__.*__$).{1,200}$" | ||
| 1150 | }, | ||
| 1151 | "literal_p": { | ||
| 1152 | "type": "string", | ||
| 1153 | "pattern": "^\\\\p{Cc}$" | ||
| 1154 | }, | ||
| 1155 | "nested": { | ||
| 1156 | "type": "object", | ||
| 1157 | "properties": { | ||
| 1158 | "inner_field": { | ||
| 1159 | "type": "string", | ||
| 1160 | "pattern": "\\P{L}+" | ||
| 1161 | } | ||
| 1162 | } | ||
| 1163 | }, | ||
| 1164 | "union_field": { | ||
| 1165 | "anyOf": [ | ||
| 1166 | { | ||
| 1167 | "type": "string", | ||
| 1168 | "pattern": "\\p{N}+" | ||
| 1169 | }, | ||
| 1170 | { | ||
| 1171 | "type": "null" | ||
| 1172 | } | ||
| 1173 | ] | ||
| 1174 | } | ||
| 1175 | }, | ||
| 1176 | "required": ["field"] | ||
| 1177 | } | ||
| 1178 | }] | ||
| 1179 | }` | ||
| 1180 | |||
| 1181 | translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false) | ||
| 1182 | tools := gjson.GetBytes(translated, "tools").Array() | ||
| 1183 | if len(tools) == 0 { | ||
| 1184 | t.Fatalf("expected tools in translated payload, got: %s", translated) | ||
| 1185 | } | ||
| 1186 | params := tools[0].Get("parameters") | ||
| 1187 | |||
| 1188 | // The \p{...} pattern on field must be removed to avoid Python re failure upstream | ||
| 1189 | if params.Get("properties.field.pattern").Exists() { | ||
| 1190 | t.Errorf("expected properties.field.pattern to be removed, got %v", params.Get("properties.field.pattern").Raw) | ||
| 1191 | } | ||
| 1192 | // Other attributes on field must be preserved | ||
| 1193 | if got := params.Get("properties.field.type").String(); got != "string" { | ||
| 1194 | t.Errorf("expected properties.field.type == 'string', got %q", got) | ||
| 1195 | } | ||
| 1196 | if got := params.Get("properties.field.description").String(); got != "field to replace" { | ||
| 1197 | t.Errorf("expected properties.field.description == 'field to replace', got %q", got) | ||
| 1198 | } | ||
| 1199 | |||
| 1200 | // Valid patterns without unescaped \p must be preserved | ||
| 1201 | if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" { | ||
| 1202 | t.Errorf("expected asset_id.pattern preserved, got %q", got) | ||
| 1203 | } | ||
| 1204 | if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" { | ||
| 1205 | t.Errorf("expected lookahead_safe.pattern preserved, got %q", got) | ||
| 1206 | } | ||
| 1207 | if got := params.Get("properties.literal_p.pattern").String(); got != "^\\\\p{Cc}$" { | ||
| 1208 | t.Errorf("expected literal_p.pattern preserved, got %q", got) | ||
| 1209 | } | ||
| 1210 | |||
| 1211 | // Nested object with \P{L}+ must have its pattern removed | ||
| 1212 | if params.Get("properties.nested.properties.inner_field.pattern").Exists() { | ||
| 1213 | t.Errorf("expected properties.nested.properties.inner_field.pattern to be removed, got %v", params.Get("properties.nested.properties.inner_field.pattern").Raw) | ||
| 1214 | } | ||
| 1215 | if got := params.Get("properties.nested.properties.inner_field.type").String(); got != "string" { | ||
| 1216 | t.Errorf("expected nested inner_field.type preserved, got %q", got) | ||
| 1217 | } | ||
| 1218 | |||
| 1219 | // Union anyOf with \p{N}+ must have its pattern removed | ||
| 1220 | if params.Get("properties.union_field.anyOf.0.pattern").Exists() { | ||
| 1221 | t.Errorf("expected union_field.anyOf.0.pattern to be removed, got %v", params.Get("properties.union_field.anyOf.0.pattern").Raw) | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | // Required array must be preserved | ||
| 1225 | if got := params.Get("required.0").String(); got != "field" { | ||
| 1226 | t.Errorf("expected required.0 == 'field', got %q", got) | ||
| 1227 | } | ||
| 1228 | } | ||
| 1229 | |||
| 1230 | func TestConvertClaudeRequestToCodex_StripsPatternPropertiesIncompatibleKeys(t *testing.T) { | ||
| 1231 | inputJSON := `{ | ||
| 1232 | "model": "gpt-5.6", | ||
| 1233 | "messages": [{"role": "user", "content": "hello"}], | ||
| 1234 | "tools": [{ | ||
| 1235 | "name": "pattern_tool", | ||
| 1236 | "input_schema": { | ||
| 1237 | "type": "object", | ||
| 1238 | "patternProperties": { | ||
| 1239 | "^\\\\p{L}+$": { | ||
| 1240 | "type": "string" | ||
| 1241 | }, | ||
| 1242 | "^[a-z]+$": { | ||
| 1243 | "type": "number" | ||
| 1244 | } | ||
| 1245 | } | ||
| 1246 | } | ||
| 1247 | }] | ||
| 1248 | }` | ||
| 1249 | |||
| 1250 | translated := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false) | ||
| 1251 | tools := gjson.GetBytes(translated, "tools").Array() | ||
| 1252 | if len(tools) == 0 { | ||
| 1253 | t.Fatalf("expected tools in translated payload, got: %s", translated) | ||
| 1254 | } | ||
| 1255 | params := tools[0].Get("parameters") | ||
| 1256 | |||
| 1257 | patternProps := params.Get("patternProperties").Map() | ||
| 1258 | if _, exists := patternProps[`^\p{L}+$`]; exists { | ||
| 1259 | t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw) | ||
| 1260 | } | ||
| 1261 | if _, exists := patternProps[`^[a-z]+$`]; !exists { | ||
| 1262 | t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw) | ||
| 1263 | } | ||
| 1264 | } | ||
| @@ -388,8 +388,43 @@ func normalizeObjectSchemaProperties(schema any) any { · 387 unmodified lines | |||
| 388 | value["properties"] = map[string]any{} | 388 | value["properties"] = map[string]any{} |
| 389 | } | 389 | } |
| 390 | } | 390 | } |
| 391 | for key, child := range value { | 391 | if patternVal, ok := value["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) { |
| 392 | value[key] = normalizeObjectSchemaProperties(child) | 392 | delete(value, "pattern") |
| 393 | } | ||
| 394 | |||
| 395 | // Inspect regex keys under patternProperties | ||
| 396 | if patternProps, ok := value["patternProperties"].(map[string]any); ok { | ||
| 397 | for patternKey, subSchema := range patternProps { | ||
| 398 | if util.HasUnsupportedUnicodePropertyEscape(patternKey) { | ||
| 399 | delete(patternProps, patternKey) | ||
| 400 | } else { | ||
| 401 | patternProps[patternKey] = normalizeObjectSchemaProperties(subSchema) | ||
| 402 | } | ||
| 403 | } | ||
| 404 | } | ||
| 405 | |||
| 406 | for _, mapKey := range util.SchemaMapKeywords { | ||
| 407 | if mapKey == "patternProperties" { | ||
| 408 | continue | ||
| 409 | } | ||
| 410 | if subMap, ok := value[mapKey].(map[string]any); ok { | ||
| 411 | for subKey, subSchema := range subMap { | ||
| 412 | subMap[subKey] = normalizeObjectSchemaProperties(subSchema) | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | for _, valKey := range util.SchemaValueKeywords { | ||
| 418 | if val, exists := value[valKey]; exists { | ||
| 419 | switch sub := val.(type) { | ||
| 420 | case map[string]any: | ||
| 421 | value[valKey] = normalizeObjectSchemaProperties(sub) | ||
| 422 | case []any: | ||
| 423 | for i, item := range sub { | ||
| 424 | sub[i] = normalizeObjectSchemaProperties(item) | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } | ||
| 393 | } | 428 | } |
| 394 | return value | 429 | return value |
| 395 | case []any: | 430 | case []any: |
| @@ -1017,3 +1017,142 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t · 1016 unmodified lines | |||
| 1017 | } | 1017 | } |
| 1018 | } | 1018 | } |
| 1019 | } | 1019 | } |
| 1020 | |||
| 1021 | func TestConvertClaudeRequestToOpenAI_StripsUnsupportedUnicodePropertyEscapePatterns(t *testing.T) { | ||
| 1022 | inputJSON := []byte(`{ | ||
| 1023 | "model": "gpt-5.6", | ||
| 1024 | "messages": [{"role": "user", "content": "hello"}], | ||
| 1025 | "tools": [{ | ||
| 1026 | "name": "Artifact", | ||
| 1027 | "description": "Render an HTML file to an Artifact", | ||
| 1028 | "input_schema": { | ||
| 1029 | "type": "object", | ||
| 1030 | "properties": { | ||
| 1031 | "field": { | ||
| 1032 | "type": "string", | ||
| 1033 | "description": "field to replace", | ||
| 1034 | "pattern": "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\"\\\\./[\\]]{1,200}$" | ||
| 1035 | }, | ||
| 1036 | "asset_id": { | ||
| 1037 | "type": "string", | ||
| 1038 | "pattern": "^[0-9a-f]{32}$" | ||
| 1039 | }, | ||
| 1040 | "lookahead_safe": { | ||
| 1041 | "type": "string", | ||
| 1042 | "pattern": "^(?!__.*__$).{1,200}$" | ||
| 1043 | } | ||
| 1044 | } | ||
| 1045 | } | ||
| 1046 | }] | ||
| 1047 | }`) | ||
| 1048 | |||
| 1049 | output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false) | ||
| 1050 | outputJSON := gjson.ParseBytes(output) | ||
| 1051 | |||
| 1052 | params := outputJSON.Get("tools.0.function.parameters") | ||
| 1053 | if !params.Exists() { | ||
| 1054 | t.Fatalf("expected function.parameters in output: %s", output) | ||
| 1055 | } | ||
| 1056 | |||
| 1057 | // Incompatible \p{...} pattern must be stripped | ||
| 1058 | if params.Get("properties.field.pattern").Exists() { | ||
| 1059 | t.Errorf("expected properties.field.pattern to be removed, got: %s", params.Get("properties.field.pattern").Raw) | ||
| 1060 | } | ||
| 1061 | if got := params.Get("properties.field.type").String(); got != "string" { | ||
| 1062 | t.Errorf("expected properties.field.type == 'string', got %q", got) | ||
| 1063 | } | ||
| 1064 | |||
| 1065 | // Valid patterns must remain intact | ||
| 1066 | if got := params.Get("properties.asset_id.pattern").String(); got != "^[0-9a-f]{32}$" { | ||
| 1067 | t.Errorf("expected properties.asset_id.pattern preserved, got %q", got) | ||
| 1068 | } | ||
| 1069 | if got := params.Get("properties.lookahead_safe.pattern").String(); got != "^(?!__.*__$).{1,200}$" { | ||
| 1070 | t.Errorf("expected properties.lookahead_safe.pattern preserved, got %q", got) | ||
| 1071 | } | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | func TestConvertClaudeRequestToOpenAI_PreservesNonSchemaPatternKeys(t *testing.T) { | ||
| 1075 | inputJSON := []byte(`{ | ||
| 1076 | "model": "gpt-5.6", | ||
| 1077 | "messages": [{"role": "user", "content": "hello"}], | ||
| 1078 | "tools": [{ | ||
| 1079 | "name": "config_tool", | ||
| 1080 | "input_schema": { | ||
| 1081 | "type": "object", | ||
| 1082 | "properties": { | ||
| 1083 | "regex_config": { | ||
| 1084 | "type": "object", | ||
| 1085 | "default": { | ||
| 1086 | "pattern": "\\p{L}+" | ||
| 1087 | }, | ||
| 1088 | "enum": [ | ||
| 1089 | {"pattern": "\\p{N}+"} | ||
| 1090 | ] | ||
| 1091 | }, | ||
| 1092 | "real_schema": { | ||
| 1093 | "type": "string", | ||
| 1094 | "pattern": "\\p{L}+" | ||
| 1095 | } | ||
| 1096 | } | ||
| 1097 | } | ||
| 1098 | }] | ||
| 1099 | }`) | ||
| 1100 | |||
| 1101 | output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false) | ||
| 1102 | outputJSON := gjson.ParseBytes(output) | ||
| 1103 | |||
| 1104 | params := outputJSON.Get("tools.0.function.parameters") | ||
| 1105 | if !params.Exists() { | ||
| 1106 | t.Fatalf("expected function.parameters in output: %s", output) | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | // Real schema pattern must be removed | ||
| 1110 | if params.Get("properties.real_schema.pattern").Exists() { | ||
| 1111 | t.Errorf("expected real_schema.pattern to be removed, got: %s", params.Get("properties.real_schema").Raw) | ||
| 1112 | } | ||
| 1113 | |||
| 1114 | // User data under default and enum must be PRESERVED | ||
| 1115 | if got := params.Get("properties.regex_config.default.pattern").String(); got != `\p{L}+` { | ||
| 1116 | t.Errorf("expected default.pattern preserved, got %q", got) | ||
| 1117 | } | ||
| 1118 | if got := params.Get("properties.regex_config.enum.0.pattern").String(); got != `\p{N}+` { | ||
| 1119 | t.Errorf("expected enum.0.pattern preserved, got %q", got) | ||
| 1120 | } | ||
| 1121 | } | ||
| 1122 | |||
| 1123 | func TestConvertClaudeRequestToOpenAI_StripsPatternPropertiesIncompatibleKeys(t *testing.T) { | ||
| 1124 | inputJSON := []byte(`{ | ||
| 1125 | "model": "gpt-5.6", | ||
| 1126 | "messages": [{"role": "user", "content": "hello"}], | ||
| 1127 | "tools": [{ | ||
| 1128 | "name": "pattern_tool", | ||
| 1129 | "input_schema": { | ||
| 1130 | "type": "object", | ||
| 1131 | "patternProperties": { | ||
| 1132 | "^\\\\p{L}+$": { | ||
| 1133 | "type": "string" | ||
| 1134 | }, | ||
| 1135 | "^[a-z]+$": { | ||
| 1136 | "type": "number" | ||
| 1137 | } | ||
| 1138 | } | ||
| 1139 | } | ||
| 1140 | }] | ||
| 1141 | }`) | ||
| 1142 | |||
| 1143 | output := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false) | ||
| 1144 | outputJSON := gjson.ParseBytes(output) | ||
| 1145 | |||
| 1146 | params := outputJSON.Get("tools.0.function.parameters") | ||
| 1147 | if !params.Exists() { | ||
| 1148 | t.Fatalf("expected function.parameters in output: %s", output) | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | patternProps := params.Get("patternProperties").Map() | ||
| 1152 | if _, exists := patternProps[`^\p{L}+$`]; exists { | ||
| 1153 | t.Errorf("expected patternProperties key '^\\\\p{L}+$' to be removed, got: %s", params.Get("patternProperties").Raw) | ||
| 1154 | } | ||
| 1155 | if _, exists := patternProps[`^[a-z]+$`]; !exists { | ||
| 1156 | t.Errorf("expected patternProperties key '^[a-z]+$' to be preserved, got: %s", params.Get("patternProperties").Raw) | ||
| 1157 | } | ||
| 1158 | } | ||
| @@ -120,3 +120,52 @@ func mergeClaudeSchemaRequired(root map[string]json.RawMessage, branchRequired j · 119 unmodified lines | |||
| 120 | root["required"] = requiredRaw | 120 | root["required"] = requiredRaw |
| 121 | } | 121 | } |
| 122 | } | 122 | } |
| 123 | |||
| 124 | // HasUnsupportedUnicodePropertyEscape reports whether a regular expression string | ||
| 125 | // contains unescaped Unicode property escape sequences (\p{...} or \P{...}). | ||
| 126 | // Python's built-in re module (and schema validators relying on it) fails compilation | ||
| 127 | // with "bad escape \p" on these sequences. | ||
| 128 | func HasUnsupportedUnicodePropertyEscape(pattern string) bool { | ||
| 129 | for i := 0; i < len(pattern); i++ { | ||
| 130 | if pattern[i] != '\\' { | ||
| 131 | continue | ||
| 132 | } | ||
| 133 | if i+2 < len(pattern) && | ||
| 134 | (pattern[i+1] == 'p' || pattern[i+1] == 'P') && | ||
| 135 | pattern[i+2] == '{' { | ||
| 136 | return true | ||
| 137 | } | ||
| 138 | i++ // skip the escaped character (including escaped backslash) | ||
| 139 | } | ||
| 140 | return false | ||
| 141 | } | ||
| 142 | |||
| 143 | // SchemaMapKeywords lists JSON Schema keywords whose values are maps of subschemas. | ||
| 144 | var SchemaMapKeywords = [...]string{ | ||
| 145 | "properties", | ||
| 146 | "$defs", | ||
| 147 | "definitions", | ||
| 148 | "patternProperties", | ||
| 149 | "dependentSchemas", | ||
| 150 | "dependencies", | ||
| 151 | } | ||
| 152 | |||
| 153 | // SchemaValueKeywords lists JSON Schema keywords with a single nested subschema or a slice of subschemas. | ||
| 154 | var SchemaValueKeywords = [...]string{ | ||
| 155 | "items", | ||
| 156 | "prefixItems", | ||
| 157 | "contains", | ||
| 158 | "additionalProperties", | ||
| 159 | "propertyNames", | ||
| 160 | "unevaluatedProperties", | ||
| 161 | "unevaluatedItems", | ||
| 162 | "additionalItems", | ||
| 163 | "contentSchema", | ||
| 164 | "anyOf", | ||
| 165 | "oneOf", | ||
| 166 | "allOf", | ||
| 167 | "not", | ||
| 168 | "if", | ||
| 169 | "then", | ||
| 170 | "else", | ||
| 171 | } | ||
| @@ -112,3 +112,81 @@ func TestNormalizeClaudeToolInputSchema(t *testing.T) { · 111 unmodified lines | |||
| 112 | }) | 112 | }) |
| 113 | } | 113 | } |
| 114 | } | 114 | } |
| 115 | |||
| 116 | func TestHasUnsupportedUnicodePropertyEscape(t *testing.T) { | ||
| 117 | tests := []struct { | ||
| 118 | name string | ||
| 119 | pattern string | ||
| 120 | want bool | ||
| 121 | }{ | ||
| 122 | { | ||
| 123 | name: "Artifact regex with multiple \\p escapes", | ||
| 124 | pattern: `^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\./[\]]{1,200}$`, | ||
| 125 | want: true, | ||
| 126 | }, | ||
| 127 | { | ||
| 128 | name: "Uppercase \\P property escape", | ||
| 129 | pattern: `^\P{L}+$`, | ||
| 130 | want: true, | ||
| 131 | }, | ||
| 132 | { | ||
| 133 | name: "Escaped backslash before p is literal and safe", | ||
| 134 | pattern: `^\\p{Cc}$`, | ||
| 135 | want: false, | ||
| 136 | }, | ||
| 137 | { | ||
| 138 | name: "Three backslashes means literal backslash plus real \\p escape", | ||
| 139 | pattern: `^\\\p{Cc}$`, | ||
| 140 | want: true, | ||
| 141 | }, | ||
| 142 | { | ||
| 143 | name: "Four backslashes means two literal backslashes", | ||
| 144 | pattern: `^\\\\p{Cc}$`, | ||
| 145 | want: false, | ||
| 146 | }, | ||
| 147 | { | ||
| 148 | name: "Standard hex character pattern", | ||
| 149 | pattern: `^[0-9a-f]{32}$`, | ||
| 150 | want: false, | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | name: "Negative lookahead without unicode properties", | ||
| 154 | pattern: `^(?!__.*__$).{1,200}$`, | ||
| 155 | want: false, | ||
| 156 | }, | ||
| 157 | { | ||
| 158 | name: "Backreference safe in Python re", | ||
| 159 | pattern: `^(a)\1$`, | ||
| 160 | want: false, | ||
| 161 | }, | ||
| 162 | { | ||
| 163 | name: "Trailing single backslash", | ||
| 164 | pattern: `abc\`, | ||
| 165 | want: false, | ||
| 166 | }, | ||
| 167 | { | ||
| 168 | name: "Backslash followed by p without brace", | ||
| 169 | pattern: `\p`, | ||
| 170 | want: false, | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | name: "Backslash followed by p and brace without close", | ||
| 174 | pattern: `\p{`, | ||
| 175 | want: true, | ||
| 176 | }, | ||
| 177 | { | ||
| 178 | name: "Empty string", | ||
| 179 | pattern: ``, | ||
| 180 | want: false, | ||
| 181 | }, | ||
| 182 | } | ||
| 183 | |||
| 184 | for _, tt := range tests { | ||
| 185 | t.Run(tt.name, func(t *testing.T) { | ||
| 186 | got := HasUnsupportedUnicodePropertyEscape(tt.pattern) | ||
| 187 | if got != tt.want { | ||
| 188 | t.Errorf("HasUnsupportedUnicodePropertyEscape(%q) = %v, want %v", tt.pattern, got, tt.want) | ||
| 189 | } | ||
| 190 | }) | ||
| 191 | } | ||
| 192 | } | ||