mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
71fa167614
* fix(documents): the rename cascade matches a legacy title's links in their escaped form, so a title containing | \ or ] no longer leaves its links stale or rewrites a link to another target (BUG-2806) * test(documents): a linker holding only the escaped link must be found by the scan (BUG-2806) * docs(links,models): the ReplaceTitle limitation notes reflect the escaped cascade (BUG-2806) * fix(documents): a link is decided by the wiki grammar, so every encoding of a legacy title's link is rewritten, and the scan allocates nothing under the cascade's memory bound (BUG-2806) * refactor(links): drop the replaceAll helper ReplaceTitle no longer uses, keeping its termination reasoning on ReplaceTitle (BUG-2806) * fix(documents): the refusal of | and \ in a new title gives its true reason, since the rename cascade now finds their escaped links (BUG-2806)
196 lines
8.9 KiB
Go
196 lines
8.9 KiB
Go
package models
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The two mechanisms a stored wiki-link bracket passes through, mirrored from
|
|
// web/src/lib/utils/markdown.ts so this test can derive the validator's rule
|
|
// from them instead of from an opinion about which characters look dangerous.
|
|
//
|
|
// Duplicated rather than approximated — and duplication is exactly the risk
|
|
// here, stated rather than papered over: these are STATIC copies, so a change
|
|
// to the TypeScript does not reach them. Nothing in this repository fails when
|
|
// the two drift (codex round 6). What this buys is that the Go rule is derived
|
|
// from a written-down grammar rather than from an opinion, and that a reader
|
|
// can check the two by eye at the cited line numbers. Closing the drift for
|
|
// real would need a shared fixture driven from both languages.
|
|
var (
|
|
// markdown.ts::WIKI_LINK_PATTERN_SOURCE — shared by renderMarkdown and wikiLinksToMarkdown.
|
|
storedWikiLinkBracket = regexp.MustCompile(`\[\[((?:\\.|[^\]\\])+)\]\]`)
|
|
// markdown.ts::unescapeWikiBody — unescapeWikiBody, applied before title comparison.
|
|
wikiBodyEscape = regexp.MustCompile(`\\(\\|\]|\|)`)
|
|
)
|
|
|
|
// roundTrips reports whether emitting `[[title]]` the way links.ReplaceTitle
|
|
// does — plain concatenation, no escaping — produces a bracket that reads back
|
|
// as exactly this title.
|
|
//
|
|
// Both layers, because either alone gives a wrong answer. The grammar alone
|
|
// accepts `A\\B` (a valid escape pair) which the unescaper then turns into
|
|
// `A\B`, a different title. The unescaper alone accepts `A]B`, which the
|
|
// grammar cuts short.
|
|
func roundTrips(title string) bool {
|
|
emitted := "[[" + title + "]]"
|
|
m := storedWikiLinkBracket.FindStringSubmatch(emitted)
|
|
if m == nil || m[0] != emitted {
|
|
// No match, or a match over a PREFIX of the emission — the latter is
|
|
// BUG-2796's early-termination defect, where `[[A]] [[A]]` matched
|
|
// twice and neither match was the renamed document.
|
|
return false
|
|
}
|
|
return wikiBodyEscape.ReplaceAllString(m[1], "$1") == title
|
|
}
|
|
|
|
// TestDocumentTitleValidationMatchesTheRoundTripProperty is the justification
|
|
// for the SYNTAX rule, in executable form: of the titles this table covers —
|
|
// all of them within the length bound — the validator must reject one if and
|
|
// only if its links would not survive being rewritten to it.
|
|
//
|
|
// Scoped to syntax deliberately. ValidateDocumentTitle also enforces length
|
|
// and non-emptiness, and those are NOT round-trip properties: a 300-rune title
|
|
// round-trips perfectly and is still refused, for the unrelated reason that it
|
|
// is an amplification factor on other documents. Stating the biconditional
|
|
// over the whole validator would be false (codex round 6); the length rule has
|
|
// its own tests below.
|
|
//
|
|
// Both directions are asserted, because either alone permits a wrong answer.
|
|
// Without the reject leg, a validator that accepted everything passes.
|
|
// Without the accept leg, a validator that rejected `[`, `|` or a lone `\`
|
|
// passes — and that is not hypothetical: the first version of this fix banned
|
|
// `]`, `\` and `|` as a character class, and this test refuted two thirds of
|
|
// it. `|` in particular is a title shape resolveWikiBody contains a dedicated
|
|
// branch to support.
|
|
func TestDocumentTitleValidationMatchesTheRoundTripProperty(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
title string
|
|
}{
|
|
// Must be REJECTED — each breaks the round trip by a different
|
|
// mechanism.
|
|
{"bug-2796 repro", `A]] [[A`},
|
|
{"single close bracket", `Alpha]Beta`},
|
|
{"trailing backslash", `Alpha\`},
|
|
{"escaped backslash", `Alpha\\Beta`},
|
|
{"escaped pipe", `Alpha\|Beta`},
|
|
|
|
// Must be ACCEPTED — each looks like wiki-link syntax and none of
|
|
// them breaks anything.
|
|
{"open bracket", `Alpha[Beta`},
|
|
{"double open bracket", `Alpha[[Beta`},
|
|
{"collection-qualified shape", `collection/Title`},
|
|
{"colons", `Ratio 1:2`},
|
|
{"ordinary punctuation", `Title (draft) — v2`},
|
|
{"non-latin", `Ünïcödé título`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
wantAccept := roundTrips(tc.title)
|
|
msg := ValidateDocumentTitle(tc.title)
|
|
gotAccept := msg == ""
|
|
if gotAccept != wantAccept {
|
|
t.Errorf("title %q: round-trips=%v but validator accepts=%v (message: %q)\n"+
|
|
"the validator and the renderers must agree — a rejected title that renders "+
|
|
"correctly is a refusal of valid input; an accepted title that does not is BUG-2796",
|
|
tc.title, wantAccept, gotAccept, msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDocumentTitleBoundIsTheRuledNumber pins the VALUE, not just the
|
|
// behaviour around it.
|
|
//
|
|
// Every other length test derives its inputs from MaxDocumentTitleRunes, so
|
|
// changing the constant to 512 would leave them all green (codex round 4).
|
|
// That is fine for arithmetic but wrong for this number: 255 is a product
|
|
// decision Dave ruled on for BUG-2798, and a silent change to it is a silent
|
|
// change to how much amplification the cheap door lets through. Changing it
|
|
// should require editing this line and noticing why.
|
|
//
|
|
// Deliberately NOT done for store.MaxRenameCascadeRetainedBytes: that one is
|
|
// mine, chosen with a measured receipt in its doc comment, and is expected to
|
|
// be tuned if the measurements change.
|
|
func TestDocumentTitleBoundIsTheRuledNumber(t *testing.T) {
|
|
if MaxDocumentTitleRunes != 255 {
|
|
t.Errorf("MaxDocumentTitleRunes = %d, want 255 (Dave's day-63 ruling on BUG-2798). "+
|
|
"If this is a deliberate product change, update the ruling reference too.",
|
|
MaxDocumentTitleRunes)
|
|
}
|
|
}
|
|
|
|
// TestDocumentTitleLengthBoundCountsRunesNotBytes pins the bound at 255
|
|
// CHARACTERS, which is what the ruling says and what a UI counter shows.
|
|
//
|
|
// The multibyte legs are the counterfactual: 255 four-byte runes are 1020
|
|
// bytes, so an implementation that reached for len() would reject them. That
|
|
// implementation would be wrong in a user-visible way — an ordinary
|
|
// 255-character title in a non-Latin script refused — while every ASCII-only
|
|
// test stayed green.
|
|
func TestDocumentTitleLengthBoundCountsRunesNotBytes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
title string
|
|
accept bool
|
|
}{
|
|
{"at the bound, ascii", strings.Repeat("a", MaxDocumentTitleRunes), true},
|
|
{"one over, ascii", strings.Repeat("a", MaxDocumentTitleRunes+1), false},
|
|
{"at the bound, 2-byte runes", strings.Repeat("é", MaxDocumentTitleRunes), true},
|
|
{"at the bound, 4-byte runes", strings.Repeat("𝄞", MaxDocumentTitleRunes), true},
|
|
{"one over, 4-byte runes", strings.Repeat("𝄞", MaxDocumentTitleRunes+1), false},
|
|
{"empty", "", false},
|
|
} {
|
|
msg := ValidateDocumentTitle(tc.title)
|
|
if tc.accept && msg != "" {
|
|
t.Errorf("%s: rejected (%s), want accepted", tc.name, msg)
|
|
}
|
|
if !tc.accept && msg == "" {
|
|
t.Errorf("%s: accepted, want rejected", tc.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDocumentTitleRejectsFormsTheCascadeCannotFind covers the two characters
|
|
// whose refusal the round-trip property above does NOT explain — and that gap
|
|
// is the finding, not an oversight in the table.
|
|
//
|
|
// `Alpha|Beta` and `Alpha\Beta` both round-trip perfectly: the renderer reads
|
|
// each back as exactly the title it started from. The first version of this
|
|
// validator accepted them for precisely that reason, and it was wrong (codex
|
|
// round 9). A link to such a title can be STORED in escaped form —
|
|
// `[[Alpha\|Beta]]` — which the rename cascade, searching for the raw
|
|
// `[[Alpha|Beta]]`, will not find. The rename then succeeds and leaves those
|
|
// links pointing at a title that no longer exists.
|
|
//
|
|
// So the property a title must satisfy is stricter than "the renderer reads it
|
|
// back": it must also be one whose links the cascade can FIND. Validating
|
|
// against the display layer while the maintenance layer disagrees is the same
|
|
// mistake as the unescaped LIKE, met from the other side.
|
|
func TestDocumentTitleRejectsFormsTheCascadeCannotFind(t *testing.T) {
|
|
for _, title := range []string{`Alpha|Beta`, `Alpha\Beta`} {
|
|
// The premise: each of these DOES round-trip. If that ever stops being
|
|
// true they are rejected for the ordinary syntax reason and this test
|
|
// is no longer testing what it says.
|
|
if !roundTrips(title) {
|
|
t.Fatalf("premise: %q must round-trip, or its rejection needs no separate justification", title)
|
|
}
|
|
if ValidateDocumentTitle(title) == "" {
|
|
t.Errorf("ValidateDocumentTitle(%q) accepted a title whose links the cascade cannot find; "+
|
|
"a rename would leave them stale", title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// BUG-2806 (lead ruling): `|` and `\` stay refused in new titles, but the
|
|
// reason given is the true one. It used to say the rename cascade could not
|
|
// find their escaped links, which BUG-2806 made false.
|
|
func TestValidateDocumentTitle_PipeAndBackslashReasonIsTrue(t *testing.T) {
|
|
for _, title := range []string{"A|B", `A\B`} {
|
|
msg := ValidateDocumentTitle(title)
|
|
if !strings.Contains(msg, "not supported in document titles yet") || strings.Contains(msg, "cascade") {
|
|
t.Errorf("%q: refusal %q should give the not-yet-supported reason, not the cascade one", title, msg)
|
|
}
|
|
}
|
|
}
|