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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -69,6 +69,6 @@ require (
|
|||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
xnorm "golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router places a note with rules only, no model (hyperguild#208):
|
// 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
|
// Resolve implements Placer. It never returns an error: anything it cannot
|
||||||
// decide safely is an Exception with the reason spelled out.
|
// decide safely is an Exception with the reason spelled out.
|
||||||
func (r Router) Resolve(_ context.Context, n Note) Decision {
|
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)
|
dir, fm := norm(n.Dir), norm(n.FrontmatterWing)
|
||||||
switch {
|
switch {
|
||||||
case dir != "" && fm != "" && dir != fm:
|
case dir != "" && fm != "" && dir != fm:
|
||||||
@@ -37,20 +47,20 @@ func (r Router) Resolve(_ context.Context, n Note) Decision {
|
|||||||
case fm != "":
|
case fm != "":
|
||||||
return r.explicit(fm, "frontmatter")
|
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")
|
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 exception("", 0, "no valid default wing configured")
|
||||||
}
|
}
|
||||||
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
|
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Router) explicit(w, via string) Decision {
|
func (r Router) explicit(w, via string) Decision {
|
||||||
if !plainWingName(w) {
|
if !usableWingName(w) {
|
||||||
return exception("", 0, fmt.Sprintf("%s names %q, which is not a plain wing name", via, 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}
|
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
||||||
}
|
}
|
||||||
known, err := KnownWings(r.BrainDir)
|
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
|
// startup rather than later: a client filter that silently does nothing is
|
||||||
// worse than none.
|
// worse than none.
|
||||||
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
||||||
if defaultWing == "" || !plainWingName(defaultWing) {
|
if defaultWing == "" || !usableWingName(defaultWing) {
|
||||||
return Router{}, fmt.Errorf("default wing %q is not a plain wing name", defaultWing)
|
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
|
||||||
}
|
}
|
||||||
r := Router{BrainDir: brainDir, Default: defaultWing}
|
r := Router{BrainDir: brainDir, Default: defaultWing}
|
||||||
if clientPattern != "" {
|
if clientPattern != "" {
|
||||||
@@ -82,3 +92,42 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
|||||||
}
|
}
|
||||||
return r, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) {
|
|||||||
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
|
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
|
||||||
assert.Equal(t, wing.Exception, d.Source)
|
assert.Equal(t, wing.Exception, d.Source)
|
||||||
assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal")
|
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")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user