From 6b6d43038337bdfc676b96199b636cac4ec63343 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 20:29:09 +0200 Subject: [PATCH] fix(wing): the client filter survives cheap evasions; lookalike and reserved folder names are refused (review of #211) - client pattern is matched against the text and against an NFKC-folded, format-character-free, dash-normalised, whitespace-free form (non-breaking hyphen, zero-width characters, soft hyphen, line splits, fullwidth letters). x/text/unicode/norm was already in the module graph as an indirect dependency; this makes it direct, no new download. - folder/frontmatter wing names must be ASCII (strings.ToLower folds the Kelvin sign to 'k') and must not be sources/concepts/entities/wiki; such names are never proposed as a wing, and a reserved default is a startup error Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 --- ingestion/go.mod | 2 +- ingestion/internal/wing/router.go | 63 +++++++++++++++++++++++--- ingestion/internal/wing/router_test.go | 62 ++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/ingestion/go.mod b/ingestion/go.mod index ac4eea7..17b1996 100644 --- a/ingestion/go.mod +++ b/ingestion/go.mod @@ -69,6 +69,6 @@ require ( golang.org/x/crypto v0.51.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 - golang.org/x/text v0.39.0 // indirect + golang.org/x/text v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/ingestion/internal/wing/router.go b/ingestion/internal/wing/router.go index e5167b5..8f628db 100644 --- a/ingestion/internal/wing/router.go +++ b/ingestion/internal/wing/router.go @@ -6,6 +6,9 @@ import ( "regexp" "slices" "strings" + "unicode" + + xnorm "golang.org/x/text/unicode/norm" ) // Router places a note with rules only, no model (hyperguild#208): @@ -28,6 +31,13 @@ type Router struct { // Resolve implements Placer. It never returns an error: anything it cannot // decide safely is an Exception with the reason spelled out. func (r Router) Resolve(_ context.Context, n Note) Decision { + for _, raw := range []string{n.Dir, n.FrontmatterWing} { + if !isASCII(raw) { + // strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin + // sign becomes "k"), which would let a lookalike name a real wing. + return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw))) + } + } dir, fm := norm(n.Dir), norm(n.FrontmatterWing) switch { case dir != "" && fm != "" && dir != fm: @@ -37,20 +47,20 @@ func (r Router) Resolve(_ context.Context, n Note) Decision { case fm != "": return r.explicit(fm, "frontmatter") } - if r.ClientBlock != nil && r.ClientBlock.MatchString(n.Text) { + if r.ClientBlock != nil && matchesClient(r.ClientBlock, n.Text) { return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing") } - if r.Default == "" || !plainWingName(r.Default) { + if r.Default == "" || !usableWingName(r.Default) { return exception("", 0, "no valid default wing configured") } return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"} } func (r Router) explicit(w, via string) Decision { - if !plainWingName(w) { - return exception("", 0, fmt.Sprintf("%s names %q, which is not a plain wing name", via, w)) + if !usableWingName(w) { + return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w)) } - if w == r.Default && plainWingName(w) { + if w == r.Default { return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} } known, err := KnownWings(r.BrainDir) @@ -69,8 +79,8 @@ func (r Router) explicit(w, via string) Decision { // startup rather than later: a client filter that silently does nothing is // worse than none. func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) { - if defaultWing == "" || !plainWingName(defaultWing) { - return Router{}, fmt.Errorf("default wing %q is not a plain wing name", defaultWing) + if defaultWing == "" || !usableWingName(defaultWing) { + return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing) } r := Router{BrainDir: brainDir, Default: defaultWing} if clientPattern != "" { @@ -82,3 +92,42 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) { } return r, nil } + +// reservedWingNames are directory names under wiki/ that are not wings: the +// retired layout and the root. A note must never be proposed into one. +var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true} + +// usableWingName: a plain name that is not reserved. +func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] } + +func isASCII(s string) bool { + for _, r := range s { + if r > unicode.MaxASCII { + return false + } + } + return true +} + +// matchesClient applies the client-content pattern to the text and to two +// folded forms of it, so the cheap evasions do not defeat it: NFKC (fullwidth +// letters), format characters removed (zero-width joiners, soft hyphens), every +// dash made "-", and whitespace removed ("acme-\nclient", "acme - client"). It catches what it names and nothing else; it is a filter, +// not a classifier. +func matchesClient(re *regexp.Regexp, text string) bool { + folded := xnorm.NFKC.String(text) + var b strings.Builder + for _, r := range folded { + switch { + case unicode.Is(unicode.Cf, r): + continue + case unicode.Is(unicode.Pd, r) || r == '\u2212': + b.WriteRune('-') + default: + b.WriteRune(r) + } + } + clean := b.String() + squeezed := strings.Join(strings.Fields(clean), "") + return re.MatchString(text) || re.MatchString(squeezed) +} diff --git a/ingestion/internal/wing/router_test.go b/ingestion/internal/wing/router_test.go index 8499100..68c9ed5 100644 --- a/ingestion/internal/wing/router_test.go +++ b/ingestion/internal/wing/router_test.go @@ -81,7 +81,7 @@ func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) { d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"}) assert.Equal(t, wing.Exception, d.Source) assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal") - assert.Contains(t, d.Reason, "not a plain wing name") + assert.Contains(t, d.Reason, "not a usable wing name") }) } } @@ -112,3 +112,63 @@ func TestNewRouter(t *testing.T) { assert.Error(t, err, "a client filter that silently does nothing is worse than none") }) } + +// From the review of #211: the content filter is the only guard on the default +// lane, so it must survive the cheap evasions. +func TestRouter_ClientMarkerSurvivesCheapEvasions(t *testing.T) { + r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)} + tests := []struct{ name, text string }{ + {"non-breaking hyphen", "notes on acme‑client"}, + {"zero-width joiner inside", "notes on acme\u200d-client"}, + {"zero-width space inside", "notes on ac\u200bme-client"}, + {"split across lines", "notes on acme-\nclient"}, + {"spaced hyphen", "notes on acme - client"}, + {"fullwidth letters", "notes on acme-client"}, + {"soft hyphen", "notes on acme\u00ad-client"}, + {"en dash", "notes on acme–client"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := r.Resolve(context.Background(), wing.Note{Text: tt.text}) + assert.Equal(t, wing.Exception, d.Source, tt.text) + }) + } + t.Run("an unrelated text still files", func(t *testing.T) { + d := r.Resolve(context.Background(), wing.Note{Text: "a talk about agents and a client-server model"}) + assert.Equal(t, wing.Default, d.Source) + }) +} + +func TestRouter_FolderNamesAreAsciiAndNotReserved(t *testing.T) { + dir := routerBrain(t) + writePage(t, dir, "wiki/kubernetes/facts/a.md", "x") + r := wing.Router{BrainDir: dir, Default: "web-clip"} + tests := []struct { + name string + dir string + }{ + {"Kelvin sign lowercases to an ASCII k", "Kubernetes"}, + {"cyrillic lookalike", "hоmelab"}, + {"retired layout name: sources", "sources"}, + {"retired layout name: concepts", "concepts"}, + {"retired layout name: entities", "entities"}, + {"wiki", "wiki"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := r.Resolve(context.Background(), wing.Note{Dir: tt.dir, Text: "x"}) + assert.Equal(t, wing.Exception, d.Source) + assert.Empty(t, d.Wing, "never proposed as a wing") + }) + } + t.Run("plain ASCII case folding still works", func(t *testing.T) { + d := r.Resolve(context.Background(), wing.Note{Dir: "KubernetEs", Text: "x"}) + assert.Equal(t, wing.Explicit, d.Source) + assert.Equal(t, "kubernetes", d.Wing) + }) +} + +func TestNewRouter_AReservedDefaultIsRefused(t *testing.T) { + _, err := wing.NewRouter(routerBrain(t), "sources", "") + assert.Error(t, err) +}