From 6707edfccf1ee306d8fe46cd6b02ae57e1b7ccc0 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sun, 24 May 2026 15:48:37 +0200 Subject: [PATCH] Reverse allowlist --- internal/topdata/native.go | 82 +++++++++++++++++++- internal/topdata/topdata_test.go | 127 ++++++++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 5 deletions(-) diff --git a/internal/topdata/native.go b/internal/topdata/native.go index d088d7d..93a1529 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -3716,7 +3716,9 @@ func (r *valueResolver) resolveValue(row map[string]any, field string, value any switch typed := value.(type) { case map[string]any: if encoding, ok := r.valueEncodingForField(field); ok { - if _, hasList := typed["list"]; hasList { + _, hasList := typed["list"] + _, hasAllExcept := typed["all_except"] + if hasList || hasAllExcept { value, err := encodeConfiguredValueList(typed, encoding) if err != nil { return tlkCompiledValue{}, fmt.Errorf("field %s: %w", field, err) @@ -3792,11 +3794,23 @@ func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValue if strings.TrimSpace(encoding.Mode) != "packed_hex_list" { return "", fmt.Errorf("unsupported value encoding mode %q", encoding.Mode) } + _, hasList := obj["list"] + _, hasAllExcept := obj["all_except"] + if hasList && hasAllExcept { + return "", fmt.Errorf("list encoding object cannot contain both list and all_except") + } for key := range obj { - if key != "list" { + if key != "list" && key != "all_except" { return "", fmt.Errorf("list encoding object contains unsupported key %q", key) } } + if hasAllExcept { + values, err := expandConfiguredValueAllExcept(obj["all_except"], encoding) + if err != nil { + return "", err + } + return formatConfiguredPackedHexList(values, encoding), nil + } rawList, ok := obj["list"].([]any) if !ok { return "", fmt.Errorf("list must be an array") @@ -3809,15 +3823,75 @@ func encodeConfiguredValueList(obj map[string]any, encoding project.TopDataValue } values = append(values, expanded...) } + return formatConfiguredPackedHexList(values, encoding), nil +} + +func formatConfiguredPackedHexList(values []int, encoding project.TopDataValueEncodingConfig) string { if len(values) == 0 { - return "", nil + return "" } var builder strings.Builder builder.WriteString("0x") for _, value := range values { builder.WriteString(fmt.Sprintf("%0*X", encoding.HexWidth, value)) } - return builder.String(), nil + return builder.String() +} + +func expandConfiguredValueAllExcept(raw any, encoding project.TopDataValueEncodingConfig) ([]int, error) { + obj, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("all_except must be an object") + } + for key := range obj { + if key != "min" && key != "max" && key != "values" { + return nil, fmt.Errorf("all_except contains unsupported key %q", key) + } + } + min := encoding.Min + if rawMin, ok := obj["min"]; ok { + parsed, err := parseConfiguredListValue(rawMin, encoding) + if err != nil { + return nil, fmt.Errorf("all_except min: %w", err) + } + min = parsed + } + max := encoding.Max + if rawMax, ok := obj["max"]; ok { + parsed, err := parseConfiguredListValue(rawMax, encoding) + if err != nil { + return nil, fmt.Errorf("all_except max: %w", err) + } + max = parsed + } + if max < min { + return nil, fmt.Errorf("all_except max %d is less than min %d", max, min) + } + rawValues, ok := obj["values"].([]any) + if !ok { + return nil, fmt.Errorf("all_except values must be an array") + } + excluded := map[int]struct{}{} + for _, rawValue := range rawValues { + expanded, err := expandConfiguredValueListItem(rawValue, encoding) + if err != nil { + return nil, err + } + for _, value := range expanded { + if value < min || value > max { + return nil, fmt.Errorf("value %d outside all_except range %d-%d", value, min, max) + } + excluded[value] = struct{}{} + } + } + values := make([]int, 0, max-min+1-len(excluded)) + for value := min; value <= max; value++ { + if _, skip := excluded[value]; skip { + continue + } + values = append(values, value) + } + return values, nil } func expandConfiguredValueListItem(raw any, encoding project.TopDataValueEncodingConfig) ([]int, error) { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 3e0ef7c..97d5224 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -202,7 +202,7 @@ func TestBuildNativeEncodesConfiguredPackedHexLists(t *testing.T) { { "key": "racialtypes:human", "AvailableHeadsMale": {"list": [1, 10, 999]}, - "AvailableHeadsFemale": {"list": [{"range": [1, 3]}]}, + "AvailableHeadsFemale": {"all_except": {"min": "001", "max": "003", "values": []}}, "AvailableSkinColors": { "list": [ {"range": [0, 12]}, @@ -285,6 +285,131 @@ func TestValidateProjectRejectsInvalidConfiguredPackedHexList(t *testing.T) { } } +func TestEncodeConfiguredPackedHexListSupportsAllExcept(t *testing.T) { + encoding := project.TopDataValueEncodingConfig{ + Dataset: "racialtypes/core", + Column: "AvailableHeadsMale", + Mode: "packed_hex_list", + Min: 0, + Max: 999, + HexWidth: 3, + } + + got, err := encodeConfiguredValueList(map[string]any{ + "all_except": map[string]any{ + "min": "001", + "max": "010", + "values": []any{"002", float64(5), map[string]any{"range": []any{"007", "008"}}}, + }, + }, encoding) + if err != nil { + t.Fatalf("encodeConfiguredValueList failed: %v", err) + } + want := "0x00100300400600900A" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestEncodeConfiguredPackedHexListAllExceptUsesConfiguredBounds(t *testing.T) { + encoding := project.TopDataValueEncodingConfig{ + Dataset: "racialtypes/core", + Column: "AvailableSkinColors", + Mode: "packed_hex_list", + Min: 0, + Max: 5, + HexWidth: 2, + } + + got, err := encodeConfiguredValueList(map[string]any{ + "all_except": map[string]any{ + "values": []any{float64(1), "04"}, + }, + }, encoding) + if err != nil { + t.Fatalf("encodeConfiguredValueList failed: %v", err) + } + want := "0x00020305" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestEncodeConfiguredPackedHexListAllExceptRejectsInvalidInput(t *testing.T) { + encoding := project.TopDataValueEncodingConfig{ + Dataset: "racialtypes/core", + Column: "AvailableHeadsMale", + Mode: "packed_hex_list", + Min: 0, + Max: 999, + HexWidth: 3, + } + tests := []struct { + name string + obj map[string]any + want string + }{ + { + name: "mixed list and all_except", + obj: map[string]any{ + "list": []any{float64(1)}, + "all_except": map[string]any{"values": []any{float64(2)}}, + }, + want: "cannot contain both list and all_except", + }, + { + name: "unknown outer key", + obj: map[string]any{ + "all_except": map[string]any{"values": []any{float64(2)}}, + "notes": "unused", + }, + want: `contains unsupported key "notes"`, + }, + { + name: "unknown all_except key", + obj: map[string]any{ + "all_except": map[string]any{ + "values": []any{float64(2)}, + "notes": "unused", + }, + }, + want: `all_except contains unsupported key "notes"`, + }, + { + name: "excluded value outside effective range", + obj: map[string]any{ + "all_except": map[string]any{ + "max": "010", + "values": []any{"011"}, + }, + }, + want: "value 11 outside all_except range 0-10", + }, + { + name: "reversed effective range", + obj: map[string]any{ + "all_except": map[string]any{ + "min": "010", + "max": "009", + "values": []any{}, + }, + }, + want: "all_except max 9 is less than min 10", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := encodeConfiguredValueList(test.obj, encoding) + if err == nil { + t.Fatalf("expected error containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected error containing %q, got %q", test.want, err.Error()) + } + }) + } +} + func TestValidateProjectRejectsDuplicateEntryKeysAcrossModules(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules"))