fix(packages): stop a non-numeric version taking down registry lookup - #68
Conversation
PackageRecipe.validate() accepts any non-empty version string, but the
registry ordered versions with
sorted(versions, key=lambda v: [int(x) for x in v.split('.')])
which raises ValueError for anything that is not dotted integers. Real
recipes are full of those: a leading v (littlefs and FreeRTOS both publish
their tags that way -- the recipe example in the book uses `tag: V10.5.1`),
pre-release tags like 3.6.0-rc1, and build metadata like 1.3.1+patch2.
The blast radius is wider than the odd package itself. The key was
duplicated across get(), list_packages() and list_all_versions(), and
* get(name) with no version scans every version of that package, so one
such recipe breaks that package entirely;
* list_packages() scans every package, and PackageResolver builds its
"package not found in registry. Available: ..." message from it -- so a
single unusual recipe anywhere in the registry turns an ordinary
missing-package error into a ValueError traceback.
Replace the three copies with one version_sort_key(). Ordering: a leading
v/V is ignored; all-digit components compare numerically so 1.10.0 still
sorts above 1.9.0; any other component compares as text and ranks below a
numeric one; a -/+ suffix ranks below the same version without one, so
3.6.0-rc1 < 3.6.0. The order is total and never raises, which is the
property that matters here -- one unusual recipe must not decide whether
lookup works for the packages around it.
Deliberately not a full PEP 440 / semver implementation. That would mean a
dependency or a lot more code for a comparison the recipe format does not
specify; this defines the rules it does need and documents them.
Tests: tests/ebuild/test_package_registry.py grows from 1 case to 12,
covering v-prefixes, pre-releases, build metadata, date-stamped and
non-numeric versions, one odd version among good ones, and a totality check
over the key. Nine of the eleven behavioural cases fail against the unfixed
registry.py (checked by running them against it); the other two are the
pre-existing numeric-order test and "2024.06", which parsed as ints before.
Docs: the ordering rules are now in docs/book/book.md section 12.5.
Note on CI: master currently fails for reasons unrelated to this change --
ebuild/build/dispatch.py has a duplicated `else:` from a merge, so the
module does not parse (PRs embeddedos-org#65/embeddedos-org#66 address it), and ninja_backend.py is
missing _object_path. This branch leaves the failure set exactly as it
found it: 26 failed / 162 passed before and after, identical lists.
Verified: pytest tests/ebuild/test_package_registry.py -> 12 passed.
Verified: ruff check --select=E,F,W --ignore=E501 and mypy
--ignore-missing-imports on both changed files -> clean.
srpatcha
left a comment
There was a problem hiding this comment.
Verified — total ordering is the right shape for this
A registry lookup that crashes on one unusual recipe takes down every package around it, so making the ordering total rather than making the parser stricter is the correct instinct. version is documented as free-form, and a resolver has no business rejecting main as a version when the recipe format allows it.
The four rules are each doing work:
| Rule | Why it matters |
|---|---|
Leading v/V ignored |
Recipes copy tags verbatim; v2.9.3 and 2.9.3 are the same release |
| All-digit components numeric | 1.10.0 > 1.9.0 — the one everybody gets wrong with a plain string sort |
| Non-numeric ranks below numeric | Keeps main from outranking every real release |
-/+ suffix ranks below the bare version |
3.6.0-rc1 < 3.6.0, which is what semver means and what a user expects |
Splitting on the first - or + together is right: build metadata and a pre-release tag both need to lose to the plain version, and treating them separately invites a case where 1.0+build outranks 1.0.
Documenting it in docs/book/book.md alongside the code is what stops the next person re-deriving a different order in a second place.
Merge order
Currently red, but not because of anything here — origin/master has a SyntaxError in ebuild/build/dispatch.py that makes the suite uncollectable (2 errors during collection). Merged on top of #66, which repairs that: 217 passed. Needs #66 first, then this goes in unchanged.
Verification
Merged onto origin/master + #66 locally; pytest 217 passed.
Summary
PackageRecipe.validate()accepts any non-emptyversionstring. The registrythen ordered those versions with:
which raises
ValueErrorfor anything that is not dotted integers.That is not a hypothetical. It is most of what upstream embedded projects
actually publish:
v2.9.3ValueError: invalid literal for int() with base 10: 'v2'3.6.0-rc1ValueError: invalid literal for int() with base 10: '0-rc1'1.3.1+patch2ValueErrormainValueErrorThe leading
vis littlefs's and FreeRTOS's own tag format — the recipe examplein
docs/book/book.md§12.2 usestag: V10.5.1.The blast radius is wider than the one odd package. The same key was
duplicated in three methods, and two of them scan more than the package you
asked for:
get(name)with no version scans every version of that package, so one suchrecipe breaks that package's resolution entirely;
list_packages()scans every package, andPackageResolver._collect()builds its error message from it:
So a single unusual recipe anywhere in the registry turns an ordinary
"you typo'd a package name" error into a
ValueErrortraceback from insidethe error handler.
Reproduced against master:
Approach
Replace the three duplicated lambdas with one
version_sort_key():v/Vis ignoredv2.9.3ranks with2.9.31.10.0>1.9.0— the existing test still passes1.x<1.0-or+suffix ranks below the same version without one3.6.0-rc1<3.6.0The property that matters is that the order is total and never raises: one
unusual recipe must not decide whether lookup works for the packages around it.
Deliberately not a full PEP 440 / semver implementation. That means either a
new dependency or a great deal more code, to implement a comparison the recipe
format does not specify. This defines the rules the registry actually needs,
documents them, and stops there.
Testing
tests/ebuild/test_package_registry.pygoes from 1 case to 12, coveringv-prefixes, pre-release tags, build metadata, date-stamped versions, anentirely non-numeric version, the empty string, one odd version sitting among
good ones, and a totality check over the key itself.
Per
TESTING.md, the eleven behavioural cases were run against the unfixedregistry.py:The nine failures are all
ValueError. The two that pass are the pre-existingnumeric-ordering test and
2024.06, which happens to parse as integers — notedrather than dressed up as regression coverage.
With the fix:
12 passed.Every ordering claim in the docs table was executed, not asserted:
Lint, exactly as CI invokes them, on both changed files:
Documentation
docs/book/book.md§12.5 gains a "Version ordering" subsection stating therules above, since "a request without a version resolves to the highest
version" was previously the only description and it did not say what "highest"
meant.
Considerations and limitations
CI on this PR will be red, for reasons that predate it.
mastercurrentlyfails to import:
ebuild/build/dispatch.pyhas a duplicatedelse:from a merge, so themodule does not parse —
SyntaxError: invalid syntaxat line 133. fix: prevent false success for unsupported system backend #65 and fix: restore the package import and reconcile the merged backend behaviour #66address this.
ebuild/build/ninja_backend.pycallsself._object_path(...), which nolonger exists —
AttributeError.I measured the effect of this branch on that: with
tests/{unit,ebuild}/test_dispatch.pyexcluded (they cannot be collected atall), master is 26 failed / 162 passed, and with this branch applied it is
26 failed / 162 passed —
diffof the twoFAILEDlists is empty. Nothingintroduced, nothing masked.
tests/ebuild/test_package_registry.pyimports onlyebuild.packages.{registry,recipe}, which do not pull in the broken modules, sothese 12 tests run and pass on master today.
Other notes:
_register()reaches it even though
validate()rejects it — the ordering must be totalfor whatever is in the dict, not only for what the loader lets through.
version_sort_keyis exported (no leading underscore) because the resolverand any future constraint solver will need the same ordering, and a second
copy is how this bug got into three places to begin with.
exact string, so
2.9.3andv2.9.3remain two distinct entries even thoughthey now compare equal. Normalising keys would change
get(name, version)lookup semantics, which is a larger decision than this fix.