History log of /dokuwiki/_test/tests/ (Results 1 – 25 of 755)
Revision Date Author Comments
(<<< Hide modified files)
(Show modified files >>>)
657de3ea21-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(parser): defer GFM strikethrough to plugin ~~macro~~ syntax

The GFM strikethrough mode sorted at 130 and its entry pattern matched
the opening ~~ of plugin macros like ~~TOC~~ or ~~NOFOOTER~~, c

fix(parser): defer GFM strikethrough to plugin ~~macro~~ syntax

The GFM strikethrough mode sorted at 130 and its entry pattern matched
the opening ~~ of plugin macros like ~~TOC~~ or ~~NOFOOTER~~, consuming
them as strikethrough before the owning plugin could parse them. Sorting
it last lets those plugins claim their ~~KEYWORD~~ syntax first, while
plain ~~text~~ still falls through and renders as <del>.

show more ...

3361bf0a21-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(parser): fire PARSER_HANDLER_DONE only for the top-level parse

Handler::finalize() triggered PARSER_HANDLER_DONE for every parse,
including the per-item and per-blockquote sub-parses the built-i

fix(parser): fire PARSER_HANDLER_DONE only for the top-level parse

Handler::finalize() triggered PARSER_HANDLER_DONE for every parse,
including the per-item and per-blockquote sub-parses the built-in
Markdown list and quote modes run. A plugin hooking the event to append
instructions (such as struct's automatic schema output) then injected a
call into every list item. That call sat in front of the item's
paragraph, so GfmListblock could no longer recognise the item as tight
and left the paragraph wrapper in place, wrapping every Markdown list
item in <p>.

Mark the sub-parser handlers built by ModeRegistry::buildSubParser() so
finalize() fires PARSER_HANDLER_DONE only for the top-level document.
Sub-parses instead fire a new PARSER_SUBHANDLER_DONE event, giving
plugins that want per-item processing a dedicated hook; their results
also still reach the document event, nested inside each item's nest
instruction.

Also correct the GfmListblock nest comments, which wrongly claimed the
wrapper prevents the main Block pass from double-wrapping items in <p>;
its real purpose is to keep the item's already-finalized sub-tree opaque
to the outer finalize, as Footnote does.

The shared sub-parser mechanism (ModeRegistry::buildSubParser()) was
introduced in 309a08521.

show more ...

0579c2f819-Jul-2026 Andreas Gohr <andi@splitbrain.org>

security(media): authorize the resolved target of media write operations

The media manager computed a permission level for the request's namespace
and passed it to the media write helpers, which the

security(media): authorize the resolved target of media write operations

The media manager computed a permission level for the request's namespace
and passed it to the media write helpers, which then acted on a target that
need not live in that namespace. Reachability differs per helper:

- media_restore() took the id to restore from the separate "image"
parameter, unrelated to the namespace the permission was computed for. A
user with upload permission in any one namespace could restore revisions
of media in namespaces they cannot write.

- media_upload() and media_upload_xhr() derive the target from the upload
namespace plus a user-controlled filename that may carry its own namespace
segments, so the target can only resolve into a sub-namespace of the upload
namespace. Where that child has a stricter ACL, the parent's permission was
applied instead.

- media_metasave() was not vulnerable: its target and its permission both
derive from the same request id, so the namespaces always matched. It is
changed only for consistency.

Each helper now recomputes the permission from its resolved target id, as
media_delete() already does. The redundant $auth parameter is removed from
media_restore() and media_metasave(), which have no other callers, and
ignored but kept for compatibility in media_upload() and media_upload_xhr().

Severity: medium - an authenticated user with upload rights in one namespace
can restore media revisions in other namespaces, or write into a stricter
sub-namespace of one they can upload to; an authorization bypass affecting
media integrity, not code execution or disclosure.

fixes #4702

show more ...

3ed3408919-Jul-2026 Andreas Gohr <andi@splitbrain.org>

security(auth): use strict comparison in membership and ACL checks

Loose comparison treated numerically-equal strings as the same value, so
a superuser, manager, or ACL entry named with a numeric st

security(auth): use strict comparison in membership and ACL checks

Loose comparison treated numerically-equal strings as the same value, so
a superuser, manager, or ACL entry named with a numeric string (e.g. 1e3
or @1e3) matched any user or group whose name was numerically equal
(1000, 01000, 1000.0). Such names survive auth_nameencode() untouched,
allowing silent privilege escalation. Match names strictly in
auth_isMember() and auth_aclcheck_cb().

Severity: low - only exploitable when an administrator names a superuser,
manager or ACL group with a numeric string such as 1e3, an unusual
configuration, so realistic deployments are rarely affected.

fixes #4701

show more ...

1c00c02109-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(parser): validate inline formatting closers with a single memoized scan

The inline formatting modes only open a span when a valid closer exists
ahead. That check was a lookahead built on CONTENT

fix(parser): validate inline formatting closers with a single memoized scan

The inline formatting modes only open a span when a valid closer exists
ahead. That check was a lookahead built on CONTENT_UNTIL_PARA, tested
character by character up to the next paragraph break and re-evaluated
from scratch for every opener candidate — openers times paragraph
length. With pcre.jit=0 a crafted 32KB page took 16s and an ordinary
34KB page with long paragraphs 37s; with the JIT on (the PHP default)
the per-character lookahead exhausted the JIT stack, the match silently
failed, and the formatting — or everything after it — rendered as plain
text.

The check also decided the wrong thing. It scanned raw text, so a closer
lookalike inside content the lexer consumes atomically — a nowiki or %%
span, a backtick code span, a link, a URL — counted as a real closer
even though the mode's exit pattern can never fire there. And it ignored
the enclosing span: an inner delimiter whose only closer lay past the
closer of the mode it sits in was entered anyway, so a stray delimiter
paired with one in a following sibling span and dragged the boundary
along — the `*` in ''glob/*.conf'' joined the `*` of the next ''...''
span and corrupted the paragraph; the same held for //, ** and __ inside
monospace, and an emphasis opened inside ((...)) ran past the footnote's
)) and the enclosing bold's **.

Each formatting mode now declares its closer through
Lexer::addCloserPattern(), mirroring addExitPattern(), and the lexer
answers "does a valid closer exist ahead" with one anchored possessive
scan per range instead of a lookahead per opener:

- The scan runs left to right from the opener, hopping over opaque spans
derived from already-registered patterns — a plain or special match is
consumed in one step, an entry into a verbatim mode (nowiki, the
backtick code spans) extends to that mode's first exit — so a closer
lookalike inside consumed content is never mistaken for a closer. Each
hop finds the earliest of boundary, closer, or opaque span in a single
leftmost search, keeping the check linear.
- An opener is rejected when the nearest enclosing mode that has a closer
of its own would close before the opener's own closer, so a delimiter
that can never close within its span stays literal. That ancestor is
found by walking the mode stack past modes that declare no closer
(plugins, footnotes); the nearest guarded ancestor suffices, as it was
itself validated against its own when it opened.
- Both verdicts are memoized and reset per parse() run: a proven closer
validates every earlier candidate, and a proven closer-free range
rejects every later candidate before the next boundary. With the lexer
consuming each opened span, the whole parse is linear in document size.

Closer patterns match the closing delimiter itself with flanking context
in lookarounds — the convention exit patterns already follow — so closer
positions compare exactly across modes and a closer directly after an
inner opener is seen. AbstractFormatting derives the closer from the exit
pattern and registers it with the paragraph break as the boundary,
preserving the rule that formatting never spans paragraphs; a mode with
other needs can pass a different boundary or none.

Footnote declares its )) as a closer rather than guarding its (( entry
with a (?=.*)) lookahead, so the footnote becomes a boundary the scan
sees and formatting inside it no longer pairs across the )); its closer
takes no paragraph boundary, as footnotes are block-level. GfmEmphasis
gains a closer pattern so single * emphasis is validated the same way,
while its entry lookahead still enforces CommonMark nearest-delimiter
pairing. GfmEmphasis and GfmStrong span bodies cannot contain their
delimiter, so their in-pattern lookaheads stay linear on their own; the
GFM backtick span bodies get deterministic alternatives with possessive
quantifiers, removing their per-character backtracking.

CONTENT_UNTIL_PARA is removed: any entry pattern built with it recreates
the quadratic scan. ParallelRegex gains escapePattern() so embedded
closer fragments follow the lexer's bare-parenthesis convention, reports
PREG_JIT_STACKLIMIT_ERROR so a future JIT exhaustion surfaces instead of
silently truncating, and no longer rewrites its registered patterns in
place while compiling the compound regex.

The adversarial 32KB page drops to 0.1s, the 37-second benign page to
0.1s, and a 128KB variant stays under 0.6s.

show more ...


Parsing/Lexer/LexerTest.php
Parsing/ParserMode/FormattingTest.php
Parsing/ParserMode/GfmDeletedTest.php
Parsing/ParserMode/GfmEmphasisStrongTest.php
Parsing/ParserMode/GfmEmphasisStrongUnderscoreTest.php
Parsing/ParserMode/GfmEmphasisTest.php
Parsing/ParserMode/GfmEmphasisUnderscoreTest.php
Parsing/ParserMode/GfmStrongUnderscoreTest.php
/dokuwiki/inc/Parsing/Lexer/CloserPattern.php
/dokuwiki/inc/Parsing/Lexer/Lexer.php
/dokuwiki/inc/Parsing/Lexer/ParallelRegex.php
/dokuwiki/inc/Parsing/Lexer/StateStack.php
/dokuwiki/inc/Parsing/ParserMode/AbstractFormatting.php
/dokuwiki/inc/Parsing/ParserMode/AbstractMode.php
/dokuwiki/inc/Parsing/ParserMode/Deleted.php
/dokuwiki/inc/Parsing/ParserMode/Emphasis.php
/dokuwiki/inc/Parsing/ParserMode/Footnote.php
/dokuwiki/inc/Parsing/ParserMode/GfmBacktickDouble.php
/dokuwiki/inc/Parsing/ParserMode/GfmBacktickSingle.php
/dokuwiki/inc/Parsing/ParserMode/GfmDeleted.php
/dokuwiki/inc/Parsing/ParserMode/GfmEmphasis.php
/dokuwiki/inc/Parsing/ParserMode/GfmEmphasisStrong.php
/dokuwiki/inc/Parsing/ParserMode/GfmEmphasisStrongUnderscore.php
/dokuwiki/inc/Parsing/ParserMode/GfmEmphasisUnderscore.php
/dokuwiki/inc/Parsing/ParserMode/GfmLink.php
/dokuwiki/inc/Parsing/ParserMode/GfmStrongUnderscore.php
/dokuwiki/inc/Parsing/ParserMode/Monospace.php
/dokuwiki/inc/Parsing/ParserMode/Strong.php
/dokuwiki/inc/Parsing/ParserMode/Subscript.php
/dokuwiki/inc/Parsing/ParserMode/Superscript.php
/dokuwiki/inc/Parsing/ParserMode/Underline.php
/dokuwiki/inc/Ui/Admin.php
/dokuwiki/lib/plugins/info/syntax.php
846f53c108-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(search): lock the metadata registry read-modify-write

updateMetadataRegistry() read metadata.idx, merged in new keys and wrote
the file back without holding a lock across the sequence. Two index

fix(search): lock the metadata registry read-modify-write

updateMetadataRegistry() read metadata.idx, merged in new keys and wrote
the file back without holding a lock across the sequence. Two indexer
processes each registering a different new key could both read the same
registry and the later writer would clobber the other's key, dropping it.
A dropped key is not cleared from its collection on deletePage(), leaving
orphaned index entries.

Guard the whole read-merge-write with a dedicated metadata lock so
concurrent registrations can no longer lose a key.

show more ...

61dea71008-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(search): wait for a contended index lock and apply dperm

The Lock rewrite (c66b5ec65) made Lock::acquire() fail immediately when the
lock directory already existed, where the old indexer lock re

fix(search): wait for a contended index lock and apply dperm

The Lock rewrite (c66b5ec65) made Lock::acquire() fail immediately when the
lock directory already existed, where the old indexer lock retried until the
holder released it. Concurrent saves or indexer runs then aborted indexing
instead of serializing (recoverable only via the .indexed tag on the next
edit), and the lock directory was created without the configured directory
permissions, unlike every other directory DokuWiki creates.

Mirror the io_lock() convention: wait for a contended lock, bounded by a
tunable wait timeout, before throwing; keep clearing locks older than five
minutes as stale; and chmod the new lock directory to $conf['dperm']. The
give-up path still throws IndexLockException, so the self-healing retry via
the .indexed tag is unchanged.

show more ...

7ec464d908-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(remote): make aclCheck self-check case-insensitive-backend aware

The check gating aclCheck() for other users compared the requested user
against REMOTE_USER verbatim. On a case-insensitive auth

fix(remote): make aclCheck self-check case-insensitive-backend aware

The check gating aclCheck() for other users compared the requested user
against REMOTE_USER verbatim. On a case-insensitive auth backend a user
naming themselves in a different case than their login was treated as a
different user and wrongly denied checking their own ACL. Normalize both
names the way auth_isMember() does before comparing.

The self-check was introduced in 884caed92.

show more ...

e5358e0d08-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(infoutils): use git --pretty=reference to avoid Windows escaping

On Windows escapeshellarg() replaces percent signs with spaces, so the
--pretty=format:%h %cd argument reached git as literal tex

fix(infoutils): use git --pretty=reference to avoid Windows escaping

On Windows escapeshellarg() replaces percent signs with spaces, so the
--pretty=format:%h %cd argument reached git as literal text. Git then
echoed the format string verbatim, a truthy value that made the version
display report an empty hash and the date "h" while skipping the working
.git-directory fallback.

The named --pretty=reference format carries no percent signs on the
command line, so the shell path works on Windows too. Its "hash
(subject, date)" output is validated before being trusted.

Introduced in b9e35b2f0.

show more ...

9255329a08-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(feed): surface real fetch error instead of "unsupported status code"

DokuHTTPClient reports transport failures with negative pseudo statuses
while keeping the descriptive message in its error fi

fix(feed): surface real fetch error instead of "unsupported status code"

DokuHTTPClient reports transport failures with negative pseudo statuses
while keeping the descriptive message in its error field. SimplePie only
turns a file error into a reported error when the status code is zero, so
connection, SSL and timeout failures surfaced as "unsupported status code
-100" under allowdebug, hiding the real cause. Clamp the pseudo statuses
to zero so the descriptive error reaches the feed output.

show more ...

12ead38a08-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(httputils): canonicalize paths when building X-Accel-Redirect URLs

http_xaccel_url() compared the fullpath()-collapsed target file against a
non-canonicalized DOKU_INC and the raw savedir config

fix(httputils): canonicalize paths when building X-Accel-Redirect URLs

http_xaccel_url() compared the fullpath()-collapsed target file against a
non-canonicalized DOKU_INC and the raw savedir config value. The lib/exe entry
points define DOKU_INC as __DIR__.'/../../' and savedir defaults to the relative
'./data', so the in-tree prefix check never matched: nginx installs using
X-Accel-Redirect served in-tree files behind the /_x_accel_redirect/ escape
hatch and returned 404 without extra location config.

Canonicalize DOKU_INC and every configured root with fullpath() before the
prefix comparisons so they match the already-canonicalized file path.

The comparison lost its fullpath() wrapping in b8c2692f7, which replaced the
earlier substr($file, strlen(fullpath(DOKU_INC)) + 1) with a raw DOKU_INC
prefix check.

show more ...

3a5ba39508-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(parser): treat quotes next to bold/underline markup as smart quotes

With typography=2 a single-quoted phrase wrapped in strong or underline
markup (**'x'** / __'x'__) rendered apostrophes instea

fix(parser): treat quotes next to bold/underline markup as smart quotes

With typography=2 a single-quoted phrase wrapped in strong or underline
markup (**'x'** / __'x'__) rendered apostrophes instead of opening and
closing quotes: the lexer matches against the full subject at a byte
offset, so the singlequoteopening lookbehind sees the consumed * or _
delimiter and fails, letting apostrophe win. Add * and _ to the quote
boundary set, as / (emphasis) already was, so the surrounding markup is
recognised as a word boundary.

show more ...

42685d0d07-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(feed): flatten repeated HTTP response headers when fetching feeds

DokuHTTPClient stores a response header that occurs more than once (e.g.
multiple Set-Cookie headers) as a nested array. FeedPar

fix(feed): flatten repeated HTTP response headers when fetching feeds

DokuHTTPClient stores a response header that occurs more than once (e.g.
multiple Set-Cookie headers) as a nested array. FeedParserFile cast each
header value to a string, turning those into the literal "Array" and
emitting an "Array to string conversion" warning on every such fetch.
Normalize each value on its own so repeated headers keep all their values
in SimplePie's list representation.

show more ...

c3a14f6706-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(media): don't upscale small images in the detail previews

The detail page, media manager and media diff requested a bounding-box
resize at the full box size, so an image smaller than the box was

fix(media): don't upscale small images in the detail previews

The detail page, media manager and media diff requested a bounding-box
resize at the full box size, so an image smaller than the box was enlarged
and fetch.php generated and cached the upscaled copy.

slika 1.2 adds an $upscale option to resize/crop. getDisplayDimensions()
now takes a $fit flag mirroring fetch.php one to one and predicts the
matching no-upscale dimensions, media_resize_image()/media_mod_image()
forward $upscale, and fetch.php disables upscaling for fit=1 requests.
In-page image scaling is unchanged.

show more ...

5a285deb06-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(changelog): stop media mtime bumps from recording bogus external edits

Commit 01e8d739c ("persist external-edit detection on first read") began writing
detected external edits to the changelog a

fix(changelog): stop media mtime bumps from recording bogus external edits

Commit 01e8d739c ("persist external-edit detection on first read") began writing
detected external edits to the changelog and snapshotting the new content to the
attic via saveExternalAttic(). That is correct for pages, which archive every
revision, but wrong for media: a media file's current revision is never archived
(media only copies content to the attic on replace or delete, to save space).

So getCurrentRevisionInfo()'s external-change detection had no attic copy of the
current revision to work with: the unchanged-content guard always failed and the
old-size lookup returned 0. A mere mtime bump (touch, rsync --times, unzip) of an
unchanged media file was therefore recorded as an external edit sized as the whole
file, and since detection is now persisted, that bogus entry (plus a redundant
attic copy) became permanent.

The "before" size of an external change is now taken through a lastRevisionSize()
seam: the base reads it from the last revision's attic copy (unchanged for pages),
and MediaChangeLog reconstructs it from the previous revision's archived size plus
the size change logged for the last revision. currentContentMatchesRevision()
compares the current file size against that reconstructed size, so an unchanged
size is treated as a mere touch (mtime reset, no entry) and a changed size is a
real external edit with the correct size change.

Media no longer snapshots an external edit to the attic at all, consistent with
not archiving the current revision: it will be archived if and when the file is
later replaced. The mtime repair for an unreliable external date moved to a base
repairExternalMtime() shared by pages and media.

show more ...

6372bc8006-Jul-2026 Andreas Gohr <gohr@cosmocode.de>

fix(changelog): detect an externally restored deleted page as a re-creation

A deleted page restored outside DokuWiki with a preserved mtime predating the
deletion (cp -p from an old backup, rsync --

fix(changelog): detect an externally restored deleted page as a re-creation

A deleted page restored outside DokuWiki with a preserved mtime predating the
deletion (cp -p from an old backup, rsync --times) has content matching the
delete revision's attic copy. That copy holds the pre-delete content, so the
unchanged-content shortcut in getCurrentRevisionInfo() wrongly treated the
restore as an unchanged file, touched the mtime back to the delete revision and
kept the DELETE as the current revision. The page then stayed "deleted" on every
subsequent read.

A last recorded revision of type DELETE means the page did not exist then, so an
existing file is an external re-creation regardless of its mtime.
getCurrentRevisionInfo() now classifies the external change and delegates to
synthesizeExternalDeletion/synthesizeExternalCreate/synthesizeExternalEdit; only
the edit path keeps the unchanged-content shortcut. A re-creation records the full
file as its size change and, when the restored mtime predates the delete, is dated
just after it with an unknown date.

The getCurrentRevisionInfo() method got rather unwieldy, so it was refactored to
use three helper methods, for synthesizing the changelog data.

show more ...

9a38b8db05-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(parser): keep a mode's directly-assigned allowedModes when it also declares categories

setModeRegistry() replaced $this->allowedModes with the category-derived list
whenever a mode declared cate

fix(parser): keep a mode's directly-assigned allowedModes when it also declares categories

setModeRegistry() replaced $this->allowedModes with the category-derived list
whenever a mode declared categories, discarding any mode names the subclass had
assigned to the property directly. The old SyntaxPlugin::accepts() merged the
category modes into the existing list instead, so a mode using the
sibling-component pattern - a non-empty getAllowedTypes() plus
$this->allowedModes[] = 'plugin_foo_bar' in its constructor - kept both and its
sibling syntax nested as intended.

Merge the category-derived modes into the directly-assigned list rather than
replacing it, then deduplicate. The no-categories path is unchanged: the
directly-assigned list is used as-is.

show more ...

945681fc05-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(parser): keep an indented list from being swallowed by a table

The parser rework dropped the list-awareness lookahead from preformatted's
entry patterns ('\n (?![\*\-])' became '\n '). In base

fix(parser): keep an indented list from being swallowed by a table

The parser rework dropped the list-awareness lookahead from preformatted's
entry patterns ('\n (?![\*\-])' became '\n '). In base mode this is masked
by sort order - listblock sorts before preformatted and wins the tie - but
table mode connects preformatted as a protected sub-mode without connecting
any list mode. Inside a table an indented list line therefore entered
preformatted instead of ending the table, and the table rewriter discarded the
resulting call: the list vanished from the output and the table's section-edit
range grew to cover it, so saving via the inline table editor overwrote the
list text.

Restore the lookahead. For DokuWiki syntax it is the original '(?![\*\-])'
(Listblock treats a bare * or - as a marker). For Markdown-preferred syntax the
guard follows GfmListblock, rejecting -, *, + and ordered markers only when
followed by whitespace or end of line, so mixed dw+md / md+dw wikis are fixed
too while an indented code block that merely starts with such a character (e.g.
a *** line) still parses as code.

show more ...

ebaa848105-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): restore histogram() as a Collection method with a BC shim

The indexer rework dropped Doku_Indexer::histogram(), which plugins reach
through idx_get_indexer()->histogram() to build tag c

fix(search): restore histogram() as a Collection method with a BC shim

The indexer rework dropped Doku_Indexer::histogram(), which plugins reach
through idx_get_indexer()->histogram() to build tag clouds (e.g. tagfilter
via the 'subject' metadata index). Calling it fatalled through
LegacyIndexer::__call as an undefined method.

Add histogram() to AbstractCollection: it sums each token's frequency across
all entities and returns them ordered by frequency, filtered by min/max/minlen,
handling both length-split (fulltext) and single-group (metadata) collections.
DirectCollection overrides it for the 1:1 title case (count shared values).
LegacyIndexer::histogram() is a deprecated shim dispatching by $key to the
matching collection.

show more ...

e248eb6f05-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(parser): don't drop document content after a blank indented line

Preformatted's exit pattern (?=\n[^ \t\n]) is a lookahead-only exit: it
consumes no byte so the boundary \n stays in the stream f

fix(parser): don't drop document content after a blank indented line

Preformatted's exit pattern (?=\n[^ \t\n]) is a lookahead-only exit: it
consumes no byte so the boundary \n stays in the stream for a following block
mode (an <hr> or header after an indented code block) to anchor on.

When that exit fired with nothing else consumed - a "blank" line that is empty
after its indent, followed by a column-0 line - the match was zero-width at the
current offset and the lexer's no-advance guard aborted the whole parse,
silently discarding the rest of the document. Reachable on ordinary trailing
whitespace ("abc\n \nmore") and inside footnotes.

Exempt zero-width MODE_EXIT matches from the guard: popping the mode stack is
real progress and leaves the boundary byte for the parent mode to consume on
the next iteration. The stack strictly shrinks on each such exit, so this
cannot loop; the infinite-loop protection is unchanged for every other
zero-width match. Preformatted's pattern is left as-is, so <hr>/header after an
indented code block still works, including after a blank indented line.

Add regressions for the blank-line, blank-line-after-code, and
boundary-preservation (hr after a blank indented line) cases.

show more ...

9e4de2f705-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): don't let collections release a caller-owned index lock

Indexer::addPage() locks the page index once and hands the same instance to
several collections, each of which lock() and unlock(

fix(search): don't let collections release a caller-owned index lock

Indexer::addPage() locks the page index once and hands the same instance to
several collections, each of which lock() and unlock() it around their work.

AbstractCollection::lock() locked and tracked every index for later release.
When a shared AbstractIndex was already locked by the caller, locking it again
was a no-op but the collection still tracked it, so the collection's unlock()
released the caller's lock and flipped the shared index read-only. Each later
collection then re-acquired and re-released it, leaving windows in which another
process could grab the page lock mid-operation; contention could abort addPage()
with the title index updated but the fulltext index stale.

Treat a shared index that is already writable as owned by its creator: leave its
lock untouched and only manage the locks the collection acquires itself.

Add a regression test asserting a caller-held index lock survives repeated
collection lock()/unlock() cycles.

show more ...

4e61aa7105-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): create index lock directories inside data/locks

Index locks were built by concatenating the configured lock directory and
the index name without a path separator.

That produced paths l

fix(search): create index lock directories inside data/locks

Index locks were built by concatenating the configured lock directory and
the index name without a path separator.

That produced paths like data/lockspage.index instead of
data/locks/page.index, so index lock directories were created in the
data root rather than inside the configured lock directory.

Fix this by joining the lock directory and index name with an explicit
slash, and update the lock test to assert the correct location and guard
against the old broken path.

show more ...

bc12b8fe05-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): read split index suffixes correctly

AbstractIndex::max() used /(\d)+\.idx$/ to determine the highest split
index suffix. For filenames like w10.idx or w12.idx that regex captured
only t

fix(search): read split index suffixes correctly

AbstractIndex::max() used /(\d)+\.idx$/ to determine the highest split
index suffix. For filenames like w10.idx or w12.idx that regex captured
only the last digit, so the maximum token-length group was truncated to 9.
Wildcard searches therefore skipped all fulltext index shards for words
with 10 or more characters.

Tighten the match to the basename and require the current index name
followed immediately by digits: ^<idx>(\d+)\.idx$. This captures the full
numeric suffix and also avoids counting unrelated index families that
share the same prefix, such as treating wiki2.idx as a numbered shard of
the w index.

Add regressions for both behaviors: multi-digit suffixes are parsed
correctly, and same-prefix indexes are ignored when determining the
maximum shard number.

show more ...

b1f7ba6405-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): fatals on invalid short wildcard terms

Searches such as "wiki a*" could crash in QueryEvaluator::opAnd() with
a TypeError. The query parser still emitted the wildcard term, but the
full

fix(search): fatals on invalid short wildcard terms

Searches such as "wiki a*" could crash in QueryEvaluator::opAnd() with
a TypeError. The query parser still emitted the wildcard term, but the
fulltext search later dropped it because its non-wildcard base was below
the minimum search term length.

That left the evaluator with an AND operator whose right-hand operand was
missing, causing a stack underflow during RPN evaluation.

Fix this in two places:
- filter invalid short wildcard terms already in Tokenizer::getWords()
when wildcard parsing is enabled, so they never enter the parsed query
- harden QueryEvaluator against missing operands for AND/OR/NOT to avoid
fatals if tokens disappear during later processing

Add regression tests covering the parser output for "wiki a*" and the
evaluator behavior when an operand is missing.

show more ...

e282ae5505-Jul-2026 Andreas Gohr <andi@splitbrain.org>

fix(search): search index corruption in updateTuple

Require an end-of-tuple boundary when removing an existing tuple in
TupleOps::updateTuple(), so updating row ID 17 no longer corrupts
tuples for I

fix(search): search index corruption in updateTuple

Require an end-of-tuple boundary when removing an existing tuple in
TupleOps::updateTuple(), so updating row ID 17 no longer corrupts
tuples for IDs like 170 or 171.

Add regression tests covering both replacement and deletion when one
numeric row ID is a decimal prefix of another.

show more ...

12345678910>>...31