Skip to content

fix(packages): stop a non-numeric version taking down registry lookup - #68

Open
Harshit-Vashisth wants to merge 1 commit into
embeddedos-org:masterfrom
Harshit-Vashisth:fix/registry-version-ordering
Open

fix(packages): stop a non-numeric version taking down registry lookup#68
Harshit-Vashisth wants to merge 1 commit into
embeddedos-org:masterfrom
Harshit-Vashisth:fix/registry-version-ordering

Conversation

@Harshit-Vashisth

Copy link
Copy Markdown

Summary

PackageRecipe.validate() accepts any non-empty version string. The registry
then ordered those versions with:

sorted(versions.keys(), key=lambda v: [int(x) for x in v.split('.')])

which raises ValueError for anything that is not dotted integers.

That is not a hypothetical. It is most of what upstream embedded projects
actually publish:

Version Result on master
v2.9.3 ValueError: invalid literal for int() with base 10: 'v2'
3.6.0-rc1 ValueError: invalid literal for int() with base 10: '0-rc1'
1.3.1+patch2 ValueError
main ValueError

The leading v is littlefs's and FreeRTOS's own tag format — the recipe example
in docs/book/book.md §12.2 uses tag: 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 such
    recipe breaks that package's resolution entirely;

  • list_packages() scans every package, and PackageResolver._collect()
    builds its error message from it:

    raise ResolveError(
        f"Package '{name}' not found in registry. "
        f"Available: {[r.name for r in self.registry.list_packages()]}"
    )

    So a single unusual recipe anywhere in the registry turns an ordinary
    "you typo'd a package name" error into a ValueError traceback from inside
    the error handler.

Reproduced against master:

recipe with a 'v' prefix registered fine: dict_keys(['v2.9.3'])
  get(name)              -> ValueError: invalid literal for int() with base 10: 'v2'
  list_packages()        -> ValueError: invalid literal for int() with base 10: 'v2'
  list_all_versions()    -> ValueError: invalid literal for int() with base 10: 'v2'

Approach

Replace the three duplicated lambdas with one version_sort_key():

Rule Effect
A leading v/V is ignored v2.9.3 ranks with 2.9.3
All-digit components compare numerically 1.10.0 > 1.9.0 — the existing test still passes
Any other component compares as text, ranked below any numeric one 1.x < 1.0
A - or + suffix ranks below the same version without one 3.6.0-rc1 < 3.6.0

The 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.py goes from 1 case to 12, covering
v-prefixes, pre-release tags, build metadata, date-stamped versions, an
entirely 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 unfixed
registry.py:

9 failed, 2 passed

The nine failures are all ValueError. The two that pass are the pre-existing
numeric-ordering test and 2024.06, which happens to parse as integers — noted
rather than dressed up as regression coverage.

With the fix: 12 passed.

Every ordering claim in the docs table was executed, not asserted:

1.x        vs 1.0        -> <
v2.9.3     vs 2.9.3      -> =
1.10.0     vs 1.9.0      -> >
3.6.0-rc1  vs 3.6.0      -> <

Lint, exactly as CI invokes them, on both changed files:

ruff check --select=E,F,W --ignore=E501   ->  All checks passed!
mypy --ignore-missing-imports             ->  Success: no issues found

Documentation

docs/book/book.md §12.5 gains a "Version ordering" subsection stating the
rules 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. master currently
fails to import:

I measured the effect of this branch on that: with
tests/{unit,ebuild}/test_dispatch.py excluded (they cannot be collected at
all), master is 26 failed / 162 passed, and with this branch applied it is
26 failed / 162 passeddiff of the two FAILED lists is empty. Nothing
introduced, nothing masked.

tests/ebuild/test_package_registry.py imports only
ebuild.packages.{registry,recipe}, which do not pull in the broken modules, so
these 12 tests run and pass on master today.

Other notes:

  • The empty string is included in the parametrised test because _register()
    reaches it even though validate() rejects it — the ordering must be total
    for whatever is in the dict, not only for what the loader lets through.
  • version_sort_key is exported (no leading underscore) because the resolver
    and any future constraint solver will need the same ordering, and a second
    copy is how this bug got into three places to begin with.
  • Not addressed here: the registry stores versions in a plain dict keyed by the
    exact string, so 2.9.3 and v2.9.3 remain two distinct entries even though
    they now compare equal. Normalising keys would change get(name, version)
    lookup semantics, which is a larger decision than this fix.

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 srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants