From 2a9c7d61242a85c608da37b0814e3bdcb3af664d Mon Sep 17 00:00:00 2001 From: DHEBP <152355273+DHEBP@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:53:40 -0400 Subject: [PATCH] content_filter: make contains a substring match and compare authors by key Both problems are in evaluateRule and neither can fire today. The shipped rulebook holds only rating and epoch_support rules, and nothing exposed to the frontend creates a rule, so no author or category rule can exist yet. They would have bitten whoever wired a rule editor. The contains arms for author and category were each a copy of their eq arm, so the operator did exact equality. A rule saying contains games could never match games/arcade. An author rule compared the app's stored author against a value the user typed. For a TELA app the stored author came from the DVM, which always renders the dero1 form because ADDRESS_STRING builds through rpc.NewAddressFromKeys and leaves Mainnet true, since consensus cannot depend on which network a node believes it is on. A user on a simulator copies deto1 out of their wallet. Addresses are bech32, so the prefix and the checksum both differ and the rule never matches its own author. sameINDEXAuthor already decodes both sides and compares keys, so eq now uses it. contains stays a text match, because a substring of an address cannot be decoded. An author rule naming a whole address has to use eq. Tests cover both renderings in both directions, a different author across networks, a rule value that is not an address, and substring vectors chosen so an equality compare rejects them. Mutation checked: reverting either fix fails its own test with the build still clean. --- content_filter.go | 15 ++++- content_filter_rules_test.go | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 content_filter_rules_test.go diff --git a/content_filter.go b/content_filter.go index 9915b74..59a4dca 100644 --- a/content_filter.go +++ b/content_filter.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -522,15 +523,23 @@ func (f *ContentFilter) evaluateRule(rule FilterRule, app AppInfo, effectiveRati case "eq": return app.Category == rule.Value case "contains": - return app.Category == rule.Value + return strings.Contains(app.Category, rule.Value) } case "author": switch rule.Operator { case "eq": - return app.Author == rule.Value + // Addresses have two renderings of one key: a contract always stores the + // dero1 form (ADDRESS_STRING leaves Mainnet true so consensus cannot depend + // on the node's network), while a wallet on a simulator shows deto1. They are + // bech32, so prefix AND checksum differ and a string compare reads one wallet + // as two. Compare the decoded keys, as UpdateINDEX does. + return sameINDEXAuthor(app.Author, rule.Value) case "contains": - return app.Author == rule.Value + // A substring of an address cannot be decoded, so this stays a text match. + // An author rule naming a whole address must use "eq" to survive the two + // renderings above. + return strings.Contains(app.Author, rule.Value) } } diff --git a/content_filter_rules_test.go b/content_filter_rules_test.go new file mode 100644 index 0000000..cae60b3 --- /dev/null +++ b/content_filter_rules_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 HOLOGRAM Project. All rights reserved. +// Regression tests for evaluateRule's author and category rules (issue #27). + +package main + +import "testing" + +// Two renderings of ONE key. A contract stores the dero1 form on every network, +// because ADDRESS_STRING builds through rpc.NewAddressFromKeys and leaves Mainnet +// true; consensus cannot depend on which network a node believes it is on. A wallet +// opened on a simulator shows the deto1 form of the same key. Addresses are bech32, +// so the prefix names the network and the checksum is computed over it: both halves +// of the string differ. +const ( + authorMainnet = "dero1qyw4fl3dupcg5qlrcsvcedze507q9u67lxfpu8kgnzp04aq73yheqqg2ctjn4" + authorSimulator = "deto1qyw4fl3dupcg5qlrcsvcedze507q9u67lxfpu8kgnzp04aq73yheqqgsph2ka" + otherAuthor = "dero1qy976ssakhfynpd4lnh39u7gw9spfzr9z55ckfd0yhrhsdr235glgqq28xlvm" +) + +func ruleFor(t, op, v string) FilterRule { + return FilterRule{Type: t, Operator: op, Value: v, Enabled: true} +} + +// TestAuthorRuleEqMatchesAcrossNetworkRendering is the half of issue #27 that made an +// author rule unusable the moment anyone could write one: the rule value is whatever +// the user copied out of their wallet, and the app's author is whatever the contract +// stored, so on a simulator the two are the same key spelled two ways. +func TestAuthorRuleEqMatchesAcrossNetworkRendering(t *testing.T) { + if authorMainnet == authorSimulator { + t.Fatal("the two vectors are identical; this test proves nothing") + } + + cases := []struct { + name string + appAuthor string + ruleValue string + wantMatch bool + }{ + {"same rendering", authorMainnet, authorMainnet, true}, + {"app mainnet, rule simulator", authorMainnet, authorSimulator, true}, + {"app simulator, rule mainnet", authorSimulator, authorMainnet, true}, + {"different author", authorMainnet, otherAuthor, false}, + {"different author across networks", authorSimulator, otherAuthor, false}, + {"rule is not an address", authorMainnet, "nobody", false}, + {"app author empty", "", authorMainnet, false}, + } + + f := &ContentFilter{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := AppInfo{Author: tc.appAuthor} + got := f.evaluateRule(ruleFor("author", "eq", tc.ruleValue), app, 0) + if got != tc.wantMatch { + t.Fatalf("author eq %q vs %q = %v, want %v", + tc.appAuthor, tc.ruleValue, got, tc.wantMatch) + } + }) + } +} + +// TestContainsOperatorIsSubstring is the other half of issue #27: both contains arms +// were a copy of their eq arm, so "contains" behaved as exact equality and a rule +// using it could never match a longer string. +func TestContainsOperatorIsSubstring(t *testing.T) { + f := &ContentFilter{} + + cases := []struct { + name string + ruleType string + app AppInfo + value string + wantMatch bool + }{ + {"category substring", "category", AppInfo{Category: "games/arcade"}, "arcade", true}, + {"category exact still matches", "category", AppInfo{Category: "games"}, "games", true}, + {"category absent", "category", AppInfo{Category: "games"}, "finance", false}, + {"author substring", "author", AppInfo{Author: authorMainnet}, "qyw4fl3", true}, + {"author absent", "author", AppInfo{Author: authorMainnet}, "zzzzzz", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := f.evaluateRule(ruleFor(tc.ruleType, "contains", tc.value), tc.app, 0) + if got != tc.wantMatch { + t.Fatalf("%s contains %q = %v, want %v", tc.ruleType, tc.value, got, tc.wantMatch) + } + }) + } + + // Non-vacuity: the substring cases above must be ones an equality compare would + // REJECT, or this test would pass against the defect it exists to catch. + if "games/arcade" == "arcade" || authorMainnet == "qyw4fl3" { + t.Fatal("the substring vectors are exact matches; this test cannot see the defect") + } +} + +// TestUnknownRuleShapesStayClosed pins the surrounding behaviour the fix must not +// widen: an unknown type or operator matches nothing. +func TestUnknownRuleShapesStayClosed(t *testing.T) { + f := &ContentFilter{} + app := AppInfo{Author: authorMainnet, Category: "games"} + + if f.evaluateRule(ruleFor("keyword", "eq", "games"), app, 0) { + t.Fatal("an unimplemented rule type must not match") + } + if f.evaluateRule(ruleFor("author", "not_contains", authorMainnet), app, 0) { + t.Fatal("an unimplemented operator must not match") + } +}