Sanitize ModDB release entries at the parser boundary - #375
Merged
Conversation
The beta.7 blank Manage Mods page came from a detail payload the parser waved through: Vanilla Variants carried a null among its tags, and the first render died reading it. #371 taught the installed filters to read around that shape, but useGetCompleteInstalledMods walks the raw releases array before any filter runs, so a `{ releases: [null] }` response still threw on release.modversion and a release with `tags: null` still threw inside evaluateModCompatibility. readModDetail now cleans what it hands out. A release that is not an object is dropped, its tags become a list of strings only, and the mod's own tags get the same treatment. Every reader downstream already assumes exactly that shape, so fixing it once here is smaller than guarding four call sites and it covers the ones nobody has written yet. A release whose modversion is not a string is kept with an empty one rather than dropped, because newestReleaseFileId reads releases[0] to build the download URL and removing an entry would quietly change which file "install newest" picks. Every consumer already treats a falsy modversion as unusable. Part of #370.
Zaldaryon
approved these changes
Sep 5, 2026
Zaldaryon
left a comment
Collaborator
There was a problem hiding this comment.
Approved
The parser now closes the #374 crash path at the ModDB boundary. Non-object release entries are dropped, release tags are normalized to string arrays, and non-string versions become an unusable empty version before the update scan, release list, or modpack planner reads them. The raw JSON Manage Mods regression exercises the real parser and hook.
Verification
- Exact head
82263177a625fbd7eee629d72e2f318d081d7ba6is current withdev. - Required checks
typecheck,lint,test,build (ubuntu-latest), andbuild (windows-latest)pass. - Ubuntu and Windows test matrices and SonarCloud pass. macOS is skipped by repository policy.
- Focused parser and Manage Mods tests pass.
- No blocking finding or open review thread remains.
1 task
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #370, unblocks #374. #371 stopped the crash inside the installed filters; Zaldaryon's review of the beta.8 promotion found that the filters were not the only reader:
Correct on both counts, and the fix belongs at the boundary.
Why the boundary and not the hook
readModDetailalready decides what a detail is: it rejects a badmodid, a blankname, areleasesfield that is not an array. It just stopped one level short, at the array itself, and let whatever was inside it through untouched. Seven call sites downstream then read those entries as objects with a stringmodversionand atagsarray of strings, because that is the shape the ModDB documents.Guarding the hook fixes the one path the issue named and leaves the other six holding the same assumption, including the ones nobody has written yet. Cleaning the entries once, where the JSON stops being untrusted text, is a smaller diff and it is the only version that stays true as call sites are added. It also matches what this module already does one function over:
readModSummaryhas cleanedmodidstrsandtagswithcleanStringssince it was written.No new dependency, no schema library: there is no zod validation on this endpoint to extend (#314's zod/mini schema covers the versions catalog, which is a different reader), and the checks here are three lines in the style of the rest of the file.
What the parser now guarantees
For every
parseModDetailResponseresult:tagson the mod is a list of strings. Anull, a number, a missing field or atagsthat is not a list at all reads as[].releasesholds objects only. An entry that is not an object,nullincluded, is dropped.tagson each release gets the same treatment as the mod's own.modversionon each release is a string.modversionis coerced to""rather than dropping the release, deliberately.newestReleaseFileIdreadsreleases[0]to build the download URL for the newest file, so removing an entry here would quietly change which file "install newest" picks. Every reader already treats a falsymodversionas unusable: the update scan skips it beforesemver.valid, the bulk updater matches it against a version it derived from a valid release, the modpack planner compares it for an exact match. An empty one costs that release only the version comparisons it could never have taken part in.The mod detail type now states those two guarantees (
tags: string[],releases: Record<string, unknown>[]), which letnewestReleaseFileIddrop the cast and theisRecordcheck it used to need.Consumers audited
Two functions fetch
/api/mod/{id}, and both go throughparseModDetailResponse:useQueryModfor the renderer,netHandlers.ts:85for the mod database visibility ping. Nothing bypasses the parser, so the boundary really is one. Every raw reader of.releasesor.tagson a detail, fromgrep -rn "\.releases\|\.tags" src/renderer src/domain:useGetCompleteInstalledMods.ts:71,77dmod.releases,release.modversion,release.tagstagsis a string list,modversionis a stringuseBulkUpdateMods.ts:60_mod?.releases.find(c => c.modversion === ...)_modis what the hook above stored, straight from the parserInstallMod.tsx:53mod.releases.map(r => r.modidstr)ModReleaseList.tsx:87,88,95releases.map,release.tagsintoevaluateModCompatibilityand.join(", ")tagsis always an array now,joinhad no guard at alladapters/importModpack.ts:13mod.releases.mapofmainfile,modidstr,modversion,tagsdomain/mods/importModpack.ts:167,186,198detail.releases,release.tags,release.modversioninstalledFilters.ts:48,81,92,106,120_mod?.releases,_mod?.tags,release.tagsmoddb.tsnewestReleaseFileIdreleases[0].fileidreadReleaseproducedThe
.tagshits inTagsFilter.tsx,InstalledTagsFilter.tsxandInstalledModsFilterBar.tsxare the filter state and an i18n key, not fields off a detail. No per-consumer guard was added anywhere: none of them bypasses the parser.Tests
The Manage Mods regression feeds the raw JSON through the real parser and the real hook, no sanitised fixture: a
nullentry inreleases, a release withtags: null, a release withtags: ["1.20.0", null], a release withmodversion: null, and the Vanilla Variants mod-leveltags: ["Cosmetics", "Crafting", "Storage", null], all on one payload. It asserts the page finishes loading and lists the mod, and a second case asserts the update offer still comes out right (v1.2.0: the newer 1.3.0 release had its tags come in asnull, so it reads as undeclared, exactly as it would have if the author had ticked nothing).Six parser unit tests pin the same shapes plus the empty-
modversiondecision and the pass-through of every other release field.Mutants, each applied alone against the committed tree and restored after:
readModDetaildrops the per-entry object filterreadReleasedrops the release tags cleaningreadModDetaildrops the mod-level tags cleaningThe third one is worth naming precisely. It goes red on the parser tests only, and the Manage Mods regression stays green, because #371 already taught
installedFiltersto read around anullcategory tag. That is the belt-and-braces the review asked for behaving as intended: the boundary now refuses to emit the shape, and the filters would still survive it if it ever got past.Testing
On head
8226317:npm run typecheck: passed (node, web, tests).npm run lint:ci: 0 errors, 15 pre-existing React Hooks warnings.npm run format:check: passed.npm run test:coverage: 161 files, 2037 passed, 2 skipped; 93.4% statements, 90.21% branches, 92.7% functions, 94.97% lines locally, floors 87 / 85 / 85 / 89. The CI run on this head is the reference figure if the two differ by a hundredth.npm run build:unpack: passed, Electron 44.1.1 Linux.npx vitest run tests/domain/mods/moddb.test.ts: 33 passed.Related issues
Type
Checklist
dev, notmain.npm run typecheckpasses.npm run lint:cipasses.npm run format:checkpasses.npm run test:coveragepasses, coverage at or above the floor invitest.config.ts.npm run build:unpackpasses.