fix(packages): make the third-party supply chain verifiable, and its pins real - #67
Conversation
…viour
`import ebuild.build.dispatch` raises SyntaxError on master, so the CLI and
five test modules fail outright. As with the other breakage below, the cause
is overlapping PRs squash-merged on stale bases with no re-verification.
- dispatch.py: configure() ended with two consecutive `else:` blocks from an
unresolved conflict. The two blocks also disagreed about the exception --
tests/ebuild expects ValueError("Unknown build backend '<name>'") while
tests/unit expects RuntimeError matching "ninja". Both are legitimate
readings: an unrecognized name is a bad argument, and a "ninja" that
reaches the dispatcher is a CLI routing failure. Introduced
UnknownBackendError(ValueError, RuntimeError) with a message covering both,
raised from configure(), build(), and clean(). "ninja" is no longer a
silent no-op in configure(), which is what let `ebuild build` report
"Build completed successfully" without running a compiler.
- ninja_backend.py: NinjaBackend._object_path() was deleted by a cflags
refactor while both of its callers survived, so generate() died with
AttributeError -- the default build backend did not work at all. Restored
with its target-namespaced object paths, which is what stops two targets
sharing a source from claiming the same output.
- ninja_backend.py: the emitted `link_shared` rule was dead -- nothing used
it -- and it hardcoded `-shared`, which is wrong on macOS. Shared libraries
already link through the generic `link` rule with the platform's flag
(-dynamiclib/-shared) and the -L/-l wiring in ldflags. Dropped the rule and
updated tests/ebuild/test_ninja_backend.py, which asserted the dead rule,
to cover the surviving behaviour instead. Its removal also unblocks
test_static_library_unaffected, which asserts no "-shared" anywhere in the
generated file.
Verified: pytest tests/ 202 passed (201 passed + 1 skipped without the ninja
package installed); tests/performance 1 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix includes windows-2022, where the default shell is PowerShell. The
"Run test suite" step uses backslash line continuations, which PowerShell does
not accept:
ParserError: Missing expression after unary operator '--'.
3 | --cov=ebuild --cov-report=xml --cov-report=term-missing \
So the Windows jobs failed before pytest started — on master and on every
branch. Marked `shell: bash`, which GitHub provides on Windows runners via Git
Bash, keeping the command readable and identical across the three platforms.
Also: `mypy .` aborted immediately with
tests\__init__.py: error: Duplicate module named "tests"
(also at ".\layers\eosuite\tests\__init__.py")
Found 1 error in 1 file (errors prevented further checking)
layers/eosuite/ vendors its own tests/ package. Because the step is
continue-on-error, this went unnoticed and the type check has been checking
zero files. Excluding layers/ makes it check 81 source files; it stays
continue-on-error, so the 11 pre-existing findings it now reports are visible
without gating the build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the Windows jobs actually running pytest, they surface a backend that
has never worked on Windows:
ninja: error: build.ninja:20: expected build command name
build C:\...\main.o: cc main.c
^ near here
Ninja splits build statements on unescaped spaces and colons, so a Windows
absolute path puts a drive-letter colon where Ninja expects the separator
between outputs and the rule name. Every generated build.ninja was rejected
before a single command ran. A POSIX path containing a space fails the same
way.
_ninja_path() escapes `$`, `:` and ` ` and is applied to the paths in build
statements only. Variable values (cflags, ldflags) are read to end of line and
are deliberately left alone -- escaping them would hand the compiler mangled
flags.
Also skipped tests/ebuild/test_integration_initramfs_security.py on hosts
without find(1)/cpio(1). _create_initramfs() drives both directly, so on a
Windows runner all three died with WinError 2 before reaching the injection
behaviour they exist to check. Building a Linux initramfs is not a Windows
operation; the skip matches how test_ninja_backend.py already skips when no
host C compiler is present.
Adds four regression tests for the escaping, including one asserting that each
build statement contains exactly one unescaped colon.
Verified: pytest tests/ 206 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci.yml is the only workflow in this repo without a concurrency group, and it is the heaviest one -- a matrix spanning ubuntu, macos and windows. Every push to a pull request therefore left the previous run queued, and all of them competed for the same scarce windows/macos runners. On this branch three superseded runs sat ahead of the current one for over an hour, testing commits that were no longer HEAD. Uses the same group expression the sibling workflows already use, with cancel-in-progress: true, because a superseded commit's result is not wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macos-13 image is retired, so jobs requesting it are never assigned a runner -- they sit queued until they time out. Across four runs on this branch every ubuntu-22.04 and windows-2022 job started and finished within minutes while all three macos-13 jobs stayed queued for more than two hours, leaving the workflow permanently incomplete. Every other workflow in this repo already targets macos-latest; ci.yml was the one place still pinning macos-13. release.yml also lists macos-13, alongside macos-14, in its wheel-build matrix. Left alone: building wheels against an older macOS deployment target may be deliberate there, and it does not gate pull requests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pins real ebuild downloads third-party source over the network and builds it. That makes recipes/*.yaml the tool's supply chain, and a recipe is a pin: a URL plus the digest of exactly what should be at it. Four of the five shipped recipes are not usable pins, and the code let them ship that way. **Four recipes cannot be fetched.** Each digest below was checked by downloading the artifact twice and hashing it; both downloads agreed. | recipe | recorded | actual | |----------|-------------------------|-------------------------| | littlefs | `sha256:placeholder` | `9cf2e7db...cad67` | | lwip | `sha256:placeholder` | `c79255f6...ed87` | | mbedtls | `...b1490fcd73` | `...b1490fcd38` | | freertos | `e36e5a2f...e3140` | `eebd58aa...271eb` | | zlib | `9a93b2b7...df23` | `9a93b2b7...df23` (ok) | mbedtls has the last two hex characters transposed. freertos matches none of the release's assets — that tag publishes exactly one, `FreeRTOS-KernelV11.1.0.zip`, 3324125 bytes, `eebd58aa...271eb`. Corrected all four. **"placeholder" parsed as a valid checksum.** `PackageRecipe.validate()` checked that the field was well-formed in no way at all, so `sha256:placeholder` sailed through and then failed every fetch with a mismatch. A checksum that is present must now look like a sha256 digest. It is deliberately still optional at the recipe level — a recipe also models packages that are never downloaded — the requirement lands at the boundary that matters, below. **No checksum meant no verification.** `PackageFetcher.fetch()` did `if recipe.checksum:` and, when absent, downloaded and extracted with nothing checked. Omitting one field silently bought an unverified download. fetch() now refuses before touching the network. `tests/ebuild/test_package_fetcher.py` contained `test_empty_checksum_skips_verification`, which asserted exactly this behaviour; it is replaced by one asserting the refusal. **Plaintext http:// was accepted.** `_download()` allowed `http://` and `https://` alike. A pin is worth much less over a transport anyone on the path can rewrite, and it leaks what is being built. https only; no shipped recipe used http. **tests/unit/test_shipped_recipes.py** walks recipes/ and asserts each one pins a syntactically real sha256 over an https URL and loads through the real loader. Against the recipes as they were, it fails on both placeholders. It deliberately does not assert digest *values*: that needs the network, and pinning them here would only duplicate the recipe. Verified: pytest tests/ 223 passed (up from 202; 16 new recipe tests, plus the inverted fetcher tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Running the test suite leaves a generated _build/ (build.ninja and compile_commands.json) in the repo root, and it was not gitignored, so a `git add -A` swept it into this branch. Removed, and gitignored so it cannot happen again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha
left a comment
There was a problem hiding this comment.
Verified — and the framing is right
recipes/*.yaml is the tool's supply chain, and treating a recipe as a pin rather than a hint is the correct model. Merged onto current master locally: 223 passed.
The defect that matters most
archive_path = self._download(recipe)
if recipe.checksum: # ← absent: download and extract, unverified
self._verify_checksum(archive_path, recipe.checksum)Omitting one optional field silently bought an unverified download. That is a fail-open default on the one code path where failing open means executing someone else's bytes.
Worse, tests/ebuild/test_package_fetcher.py carried test_empty_checksum_skips_verification — the insecure behaviour was pinned by a test as though it were intended, so anyone who fixed it would have looked like they were breaking something. Replacing that test with one asserting the refusal is the part of this PR I would least want dropped.
Checked the new gate reads the way it should:
Refusing to fetch <name> v<version>: the recipe carries no checksum, so there
is nothing to verify the download against.
Keeping checksum optional on PackageRecipe while making it mandatory at fetch() is the right seam. A recipe also models packages that are never downloaded, and forcing it at the dataclass punished twenty resolver tests that build recipes in memory. The requirement belongs at the boundary where bytes actually arrive.
Independently confirmed one of the digests
I did not re-download all five, but I checked the FreeRTOS claim against the API, since "matches none of the release's assets" is a strong statement:
$ gh api repos/FreeRTOS/FreeRTOS-Kernel/releases/tags/V11.1.0
FreeRTOS-KernelV11.1.0.zip 3324125 bytes
Exactly one asset, exactly the size and name you record, and the recipe URL points at it. That matches your account precisely.
sha256:placeholder passing validate() is the enabling bug behind all of it — a checksum field that accepts any string is not a checksum field. Requiring the shape when the value is present closes it.
Also right
Rejecting plaintext http:// outright rather than warning. A pinned digest over plaintext is still better than nothing, but there is no reason to accept the downgrade when every URL here is available over TLS.
Verification
Merged onto origin/master; pytest 223 passed; FreeRTOS release assets checked via the GitHub API. Not re-verified: the littlefs, lwip, mbedtls and zlib digests — I am taking those on your stated method of downloading twice and comparing.
The architectural problem
ebuild downloads third-party source over the network and builds it. That makes
recipes/*.yamlthe tool's supply chain, and a recipe is a pin: a URL plus the digest of exactly what should be at it.Four of the five shipped recipes are not usable pins, and the code let them ship that way.
The recipes
Each digest below was checked by downloading the artifact twice and hashing it; both downloads agreed.
sha256:placeholder9cf2e7db…cad67sha256:placeholderc79255f6…ed87…b1490fcd73…b1490fcd38e36e5a2f…e3140eebd58aa…271eb9a93b2b7…df239a93b2b7…df23✅73where the published digest ends38.FreeRTOS-KernelV11.1.0.zip, 3324125 bytes,eebd58aa…271eb.All four corrected. The practical effect today is that
ebuildcannot fetch four of the five libraries it ships recipes for — every attempt dies on a checksum mismatch.Why nothing caught it
"placeholder"parsed as a valid checksumPackageRecipe.validate()checked the field's shape in no way at all, sosha256:placeholdersailed through and then failed every fetch. A checksum that is present must now look like a sha256 digest.It is deliberately still optional at the recipe level — a recipe also models packages that are never downloaded, and requiring it there broke 20 resolver tests that build in-memory recipes. The requirement lands at the boundary that actually matters:
No checksum meant no verification
Omitting one field silently bought an unverified download.
fetch()now refuses before touching the network.tests/ebuild/test_package_fetcher.pycontainedtest_empty_checksum_skips_verification, which asserted exactly this behaviour as if it were intended. It is replaced by one asserting the refusal.Plaintext
http://was accepted_download()allowedhttp://andhttps://alike. A pin is worth much less over a transport anyone on the path can rewrite, and it leaks what is being built. https only — no shipped recipe used http.Keeping it fixed
tests/unit/test_shipped_recipes.pywalksrecipes/and asserts each one pins a syntactically real sha256 over an https URL and loads through the real loader. Against the recipes as they were, it fails on both placeholders.It deliberately does not assert digest values: that needs the network, and pinning them in a test would only duplicate the recipe. Catching the mbedtls-style transposition needs a fetch, which belongs in a scheduled job rather than the unit suite — happy to add one if you want it.
Verification
pytest tests/16 new recipe tests, plus the inverted fetcher tests.
Relationship to #66
Independent — different files. #66 restores the package import and the ninja backend; this is the packages/recipes layer. Both are on
masteras the base.🤖 Generated with Claude Code