| 657de3ea | 21-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 ...
|
| 3361bf0a | 21-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 ...
|
| 0579c2f8 | 19-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 ...
|
| 3ed34089 | 19-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 ...
|
| 1c00c021 | 09-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 ...
|
| 846f53c1 | 08-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 ...
|
| 61dea710 | 08-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 ...
|
| 7ec464d9 | 08-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 ...
|
| e5358e0d | 08-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 ...
|
| 9255329a | 08-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 ...
|
| 12ead38a | 08-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 ...
|
| 3a5ba395 | 08-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 ...
|
| 42685d0d | 07-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 ...
|
| c3a14f67 | 06-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 ...
|
| 5a285deb | 06-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 ...
|
| 6372bc80 | 06-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 ...
|
| 9a38b8db | 05-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 ...
|
| 945681fc | 05-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 ...
|
| ebaa8481 | 05-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 ...
|
| e248eb6f | 05-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 ...
|
| 9e4de2f7 | 05-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 ...
|
| 4e61aa71 | 05-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 ...
|
| bc12b8fe | 05-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 ...
|
| b1f7ba64 | 05-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 ...
|
| e282ae55 | 05-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 ...
|