Conversation
c457a39 to
ed287b4
Compare
1969677 to
dd51976
Compare
| @property | ||
| def _oldAddonStateFile(self) -> str: | ||
| from addonHandler import _OLD_STATE_FILENAME | ||
|
|
||
| return os.path.join(self.configDir, _OLD_STATE_FILENAME) |
There was a problem hiding this comment.
Can we make sure there is some record (e.g. issue reference and comment) to remove this vulnerability in 2027.1.
We should drop reading pickles entirely in the next API breaking release.
| log.error(f"Add-ons state file too large. {size=}B; {_MAX_STATE_FILESIZE_BYTES=}B") | ||
| return |
There was a problem hiding this comment.
should we warn the user somehow?
| # Decoding JSON can potentially consume a large amount of memory. | ||
| # To avoid issues caused by this when reading add-on state, | ||
| # limit the maximum size of the file. | ||
| # The 2026.1 state JSON with no add-ons is 244 bytes. | ||
| # As of 2026-02-24, there are 364 add-ons in the Add-on Store, | ||
| # and the longest ID is 40 UTF-8 bytes (mean ~= median ~= 13 bytes). | ||
| # Even if we take the empty state to be 300 bytes, | ||
| # and assume that all add-on IDs are 100 characters long, | ||
| # And those 100 characters are all 4 bytes wide, | ||
| # and factor in the 4 extra bytes required for the enclosing quotes and ", " separator between IDs, | ||
| # 1MiB is still enough space for a state file containing | ||
| # (1024 * 1024) / (4 + 4 * 100) ~= 2594 add-ons. | ||
| _MAX_STATE_FILESIZE_BYTES = 1024 * 1024 # 1MiB |
There was a problem hiding this comment.
we could stream this?
https://pypi.org/project/json-stream/
|
|
||
|
|
||
| def _getAddonsStateDictFromPickle(picklePath: os.PathLike) -> dict[str, list[str] | tuple[int, int, int]]: | ||
| import pickle |
There was a problem hiding this comment.
why was this import moved?
to isolate usage of pickle?
There was a problem hiding this comment.
Pull request overview
This PR migrates NVDA’s add-ons state persistence from an unsafe pickle-based format to JSON, including backward-compatible loading/migration paths and updated tests/documentation.
Changes:
- Replace add-ons state serialization/deserialization with JSON (
addonsState.json) and introduce pickle-to-JSON conversion helpers and fallback loading. - Add installer/system-config migration to convert
addonsState.pickleto JSON and back up the old file. - Update tests and changelog entries for the new JSON format and deprecations.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| user_docs/en/changes.md | Documents deprecations related to add-ons state filename and AddonsState APIs. |
| tests/unit/test_addonHandler/test_addonsState.py | Expands unit tests to cover JSON load/save and pickle-to-JSON conversion behavior. |
| source/installer.py | Adds installer-time migration from pickle to JSON for system config. |
| source/config/init.py | Updates system config copy behavior to account for JSON state and migration scenarios. |
| source/addonHandler/init.py | Implements JSON-based add-ons state IO, fallback pickle loading, and conversion helpers. |
| source/NVDAState.py | Switches state filename to JSON and adds path for legacy pickle state. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if os.path.exists(jsonPath): | ||
| tryRemoveFile(jsonPath) | ||
| except Exception: | ||
| log.error("Failed to remove existing {jsonPath}.", exc_info=True) |
There was a problem hiding this comment.
The log message uses "{jsonPath}" but isn't an f-string, so it will log the literal braces instead of the path. Make this an f-string (or pass jsonPath as a %s arg) so the error is actionable.
| log.error("Failed to remove existing {jsonPath}.", exc_info=True) | |
| log.error(f"Failed to remove existing {jsonPath}.", exc_info=True) |
| try: | ||
| jsonState = addonHandler._getAddonsStateDictFromPickle(pickledPath) | ||
| except Exception: | ||
| log.error("Failed to load pickled add-ons state.", exc_info=True) | ||
| else: | ||
| jsonPath = os.path.join(configPath, addonHandler.STATE_FILENAME) | ||
| try: | ||
| if os.path.exists(jsonPath): | ||
| tryRemoveFile(jsonPath) | ||
| except Exception: | ||
| log.error("Failed to remove existing {jsonPath}.", exc_info=True) | ||
| else: | ||
| try: | ||
| with open(jsonPath, "wt", encoding="utf-8") as file: | ||
| json.dump(jsonState, file) | ||
| except Exception: | ||
| log.error("Failed to dump JSON add-ons state.", exc_info=True) | ||
| finally: | ||
| try: | ||
| os.replace(pickledPath, pickledPath + ".bak") | ||
| except Exception: | ||
| log.error("Failed to back up pickled add-ons state.", exc_info=True) |
There was a problem hiding this comment.
Migration always renames the pickle to .bak in the finally block, even if loading the pickle failed or writing JSON failed. This can leave systemConfig with neither a readable pickle nor a JSON state file, effectively discarding add-on state. Only back up/rename the pickle after a successful JSON write (ideally write JSON to a temp file and atomically replace).
| if not isinstance(pickledState, dict): | ||
| log.debug("Invalid pickled state: {pickledState!r}") | ||
| return {} |
There was a problem hiding this comment.
This debug log message is missing an f-string, so it will print the literal "{pickledState!r}" instead of the value. Use an f-string so logs contain the actual invalid payload type/value.
| jsonState[key] = value | ||
| else: | ||
| log.debug(f"Invalid backCompatToAPIVersion: {value!r}. Discarding.") | ||
| elif key in AddonStateCategory: |
There was a problem hiding this comment.
elif key in AddonStateCategory will never match for the expected pickled keys because key is a string and EnumMeta.__contains__ only returns True for enum members. As a result, category entries like "disabledAddons"/"blocked" will be discarded during pickle->JSON conversion. Use a value-based check (e.g. try AddonStateCategory(key) in a try/except) before accepting the category.
| elif key in AddonStateCategory: | |
| elif any(key == category.value or key == category.name for category in AddonStateCategory): |
| * `textInfos.OffsetsTextInfo.allowMoveToOffsetPastEnd` is deprecated. | ||
| Use the `OffsetsTextInfo.allowMoveToUnitOffsetPastEnd` method instead. (#19152, @LeonarddeR) | ||
| * `installer.comparePreviousInstall` is deprecated with no planned replacement. (#19631) | ||
| * Deprecations in `addonHandler: (#19564) |
There was a problem hiding this comment.
Markdown formatting: the inline code span for addonHandler is missing a closing backtick, which will break rendering for the rest of the section. Close the backtick so only addonHandler is formatted as code.
| * Deprecations in `addonHandler: (#19564) | |
| * Deprecations in `addonHandler`: (#19564) |
| The system config will be in a ``systemConfig`` subdirectory of this directory. | ||
| :param addonsToCopy: List of add-on IDs to include in the system configuration, defaults to `()`. | ||
| :param isMigration: Whether this is a migration from one system config location to another, defaults to ``False``. | ||
| When this is ``True``, the ``addons/`` directory and ``addonsState.pickle`` in ``fromPath`` will be copied as-is, if they exist. |
There was a problem hiding this comment.
Docstring is now inaccurate: when isMigration is True it says only addonsState.pickle is copied as-is, but the new canonical state file is addonsState.json and may also need to be preserved during migrations. Update the docstring to reflect the current behavior/files.
| When this is ``True``, the ``addons/`` directory and ``addonsState.pickle`` in ``fromPath`` will be copied as-is, if they exist. | |
| When this is ``True``, the ``addons/`` directory and add-on state files (for example, ``addonsState.json`` and legacy ``addonsState.pickle``) in ``fromPath`` will be copied as-is, if they exist. |
| addonHandler.STATE_FILENAME.casefold(), | ||
| addonHandler._OLD_STATE_FILENAME.casefold(), | ||
| ) | ||
| files = [filename for filename in files if filename not in stateFilenames] |
There was a problem hiding this comment.
State file exclusion is casefolding the constants but not the filenames. Since files contains the original casing, this filter can fail and accidentally copy addonsState.json/pickle. Compare using filename.casefold() (or avoid casefolding constants) so the exclusion works reliably.
| files = [filename for filename in files if filename not in stateFilenames] | |
| files = [filename for filename in files if filename.casefold() not in stateFilenames] |
Link to issue number:
Closes #19559
Summary of the issue:
Addons state is saved in a pickle file.
Description of user facing changes:
None
Description of developer facing changes:
The add-ons state file is now in JSON.
The
fromPickledDictmethod onAddonsStatehas been renamed tofromDict, but a shim has been introduced.Description of development approach:
Switch to using lists instead of sets when populating AddonsState from dict and serializing to dict, as json.dump doesn't support serializing sets.
Added methods to read in a pickled add-ons state file and emit a good JSON representation of that add-ons state. My approach here was to discard invalid values rather than raising an error, so we have the greatest chance of success. This does mean that the conversion may be lossy.
When updating NVDA, convert from pickle to JSON for the system config's add-ons state file. This is done because this is essentially the only context in which we should write to the system config directory and which we know will be executed. User copies perform the migration on first run by migrating if and only if no addonsState.json exists, but addonsState.pickle does. User config migration is done by the installed copy as (a) this is the only time we can guarantee that the user's NVDA config wil be present; and (b) to minimise the amount of pickles we load while elevated. In both cases, a backup of the old state file is kept (as
addonsState.pickle.bak).When running on secure desktops, the pickled state will not be read, even if it is the only addonsState file in the system config. This should never happen, as the installer is responsible for migrating the system config's add-ons state file. In other cases, the fallback of reading the pickled state will attempt to save the JSON state and back up the pickled state, but this is not required in order for the load to succede. This is so when running in secure mode or with access to the user config restricted, add-ons continue to function properly. This does remove the security benefit of migrating away from pickle, but there's no other option other than ripping pickle support out entirely and breaking everyone's add-ons state entirely and having them manually migrate.
Testing strategy:
Unit and system tests.
Manual testing as follows:
Also "upgraded" my 2026.2 alpha copy with this launcher to ensure that the migration works as expected if updating within the 2026.x series.
Known issues with pull request:
None known (see notes above for some caviats about the approach though)
Code Review Checklist: