Skip to content

fix(deps): move rustls to 0.23.45 for RUSTSEC-2026-0285 - #1785

Merged
Jaro-c merged 2 commits into
developfrom
fix/rustls-advisory-1783
Sep 19, 2026
Merged

Jaro-c merged 2 commits into
developfrom
fix/rustls-advisory-1783

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Moves rustls from 0.23.42 to 0.23.45, which is the first version patched for RUSTSEC-2026-0285 (GHSA-2mjx-qc3c-rqvc). rustls-webpki moves with it, 0.103.13 to 0.103.15, because the resolver takes them together.
  • Lockfile only for the dependency: no manifest, no source.
  • Raises bench/binary-budget-mib from 7.5 to 8, in its own commit, because this patch is what tips the binary over the old ceiling.

Refs #1783.

Why

cargo audit and cargo deny went red on #1781, a pull request that does not touch rustls, because any pull request that changes Cargo.lock gets the lockfile audited and develop has carried the affected version since before the advisory was published on 2026-09-14. main carries it too, so v5.9.3 shipped with it.

The exposure here is low: rustls is reached only through ureq, client side, by the self-updater, the advisory leaves the handshake transcript authenticated, and every asset the updater downloads is checked against the release signature afterwards. The reason to move now is that the audit gate stays red for every dependency pull request until the lockfile does.

Changes

cargo update -p rustls, nothing else:

Updating rustls v0.23.42 -> v0.23.45
Updating rustls-webpki v0.103.13 -> v0.103.15

The binary budget

develop builds to 7,863,856 bytes, 464 under the 7.5 MiB ceiling (7,864,320) that has not moved since #1320 on 2026-08-03. With rustls 0.23.45 the binary is 7,864,880 bytes: 1,024 more than develop and 560 over. The first run of this pull request failed Release binary fits its budget on exactly that.

The growth is explained, it is a patched TLS library and nothing else changed, so by the check's own wording the ceiling moves with the reason in the commit message. I took it to 8 MiB rather than 7.6: develop was already sitting against the ceiling, so every fix that adds code would have met it next, and 8 MiB still leaves only about 512 KiB, which a heavyweight dependency does not fit in.

Test plan

  • cargo audit locally against the advisory database of today (1251 advisories): no vulnerability found
  • cargo deny check advisories: advisories ok
  • cargo test --lib update:: passes locally, which is the module that reaches rustls
  • The vendored offline Debian build, the Podman lanes and the other platforms run in this pull request's own checks; I did not run them locally

Checklist

  • Targets develop
  • Commits are signed off (DCO, git commit -s)
  • Commits are signed
  • Labels applied
  • No secrets, keys or credentials in code, logs or fixtures

Related issues

Refs #1783. I will close it by hand once this lands on develop.

Signed-off-by: Jaro-c 75870284+Jaro-c@users.noreply.github.com

rustls 0.23.42 accepted a TLS 1.3 handshake message sent at the wrong
encryption level when it shared a record with a key-changing message.
podup reaches rustls only through ureq, client side, in the self-updater,
and verifies every downloaded asset against the release signature, so the
exposure is low. The audit gate is red for every pull request that changes
the lockfile until this moves.

Lockfile only: rustls 0.23.42 -> 0.23.45, rustls-webpki 0.103.13 -> 0.103.15.

Refs #1783.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c Jaro-c added type:security Security-relevant change prio:P2 Medium priority effort:XS Extra small type:deps Dependency update area:update Subsystem: update labels Sep 19, 2026
develop builds to 7,863,856 bytes, 464 under the 7.5 MiB ceiling set on
2026-08-03 (#1320), so any change that adds code goes over it. The rustls
security patch in this branch is the first: 7,864,880 bytes, 1,024 more
than develop and 560 over.

That growth is explained, a patched TLS library, and is not a regression.
8 MiB leaves 523,728 bytes of room over this branch, which still fails a
dependency that brings a few hundred KiB with it while no longer failing
every ordinary fix.

Refs #1783.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c Jaro-c added the area:ci Subsystem: ci label Sep 19, 2026
@Jaro-c
Jaro-c merged commit 5622384 into develop Sep 19, 2026
38 checks passed
@Jaro-c
Jaro-c deleted the fix/rustls-advisory-1783 branch September 19, 2026 01:54
Jaro-c added a commit that referenced this pull request Sep 19, 2026
…1789)

## Summary

- A warm `up` asked Podman for the same image tag once per replica: N
replicas of one service that pass the config-hash check made N identical
`GET /images/{tag}/json`. It now makes one per service.
- The answer lives for one service's replica loop and nothing longer,
which is the difference from the attempt that #1755 withdrew.

Refs #1759.

## Changes

- `lifecycle::ResolvedImage`, a `tokio::sync::OnceCell<Option<String>>`,
created in `up_one_service` right after `acquire_service_image` returns
and dropped with that service's replica fan-out. `unchanged` reads the
tag's id through `get_or_try_init`.
- The cell serialises initialisation, so the replicas `join_bounded`
polls at once produce one request, not N. It is lazy: a cold `up`,
`--force-recreate`, `--no-recreate` and a changed hash still make zero
inspects.
- A failed inspect is not remembered. `get_or_try_init` leaves the cell
empty on `Err`, the replica that asked gets the same
`ComposeError::Podman` as before, and the next replica asks again.
- Not shared between services. Two services on one image cost two
inspects, deliberately: a cell that outlives one service spans a
sibling's pull or build of the same tag, which is the #1755 cache again
one size down.

## Why the earlier attempt failed and this one should not

#1755's squash message still claims this saving, but its diff touches
only `scale.rs`, `query/images.rs` and `query/inspect.rs`. The `up` half
cached the id the prefetch stage had fetched, and prefetch runs before
acquisition (`up.rs` prefetch, then `acquire_service_image` in
`up_one.rs`), so the cache spanned the pull or build of the very tag it
was about.
`recreate_on_image::a_retagged_image_recreates_an_image_service` and
`x_podman_autoupdate::up_with_autoupdate_registry_recreates_after_the_tag_moved_without_pull_flag`
refused it. Here the lookup is taken after this service's acquisition is
over, and `create_and_start` neither pulls nor builds, so nothing this
service does moves its tag while the answer is alive. Both live tests
retag between invocations, and a cell never spans two.

## What this is allowed to miss

The comment on the issue asks that somebody define what `unchanged` may
miss before this lands. Decided on 2026-09-18: a tag moved by a sibling
service in the same level, or from outside, while one service's replica
loop is running. That race already exists today and gives a mixed
service: replicas that inspected before the move are kept, the ones
after are recreated. With this change all of them see it or none does,
and the next `up` catches it. Nothing else is narrowed.

## Correction after a second review

A second review found that my first commit's doc comment said the cell
does not span a sibling service's pull or build of the same tag. It can:
services in one level start concurrently. The behaviour is the one
described in the previous section; the second commit makes the comment
say so, and I corrected the same sentence where I had written it on
#1759.

## Test plan

- [x] `cargo fmt --all --check` is clean
- [x] `cargo clippy --locked --all-targets --all-features -- -D
warnings` is clean
- [x] `cargo test --locked --lib --all-features lifecycle::`: 162
passed. The nine new ones are in `lifecycle/up_one_tests.rs` and assert
request counts against the recording fake: three replicas inspect once,
two services sharing an image once each, two images once each, a moved
tag still recreates every replica, only the replica bound to a stale
image is recreated, a failed inspect is asked again by the next replica,
a cold `up` inspects nothing, a failing inspect fails `up` with the
Podman error
- [x] New or changed checks were verified by deleting the control and
watching them fail: with only the `get_or_try_init` replaced by the old
direct `image_id` call (signature kept so it compiles, `diff -q`
confirmed the file changed), 7 of the 9 fail with the old numbers,
`left: 3 right: 1` for three replicas, and the two controls stay green
- [ ] Podman lane: I could not run the two live tests on my machine.
They fail on the first `up`, before any retag, because this Podman 5.7.0
answers 404 on the libpod image inspect for a locally built image and
podup then tries to pull `localhost/...`. The same two fail the same way
on `develop` without this change, so it says nothing either way. This
pull request's own Podman 5 and Podman 6 lanes are the proof, and those
two tests are the ones to read
- [ ] The two behaviours I argued but did not sabotage, because undoing
them is a restructure and not a revert: caching the error, and widening
the cell across services.
`a_failed_inspect_is_not_remembered_for_the_next_replica` and
`two_services_sharing_an_image_inspect_it_once_each` are the tests that
would catch them

## Checklist

- [x] Targets `develop`
- [x] Commits are signed off (DCO, `git commit -s`)
- [x] Commits are signed
- [x] Labels applied
- [x] No secrets, keys or credentials in code, logs or fixtures
- [x] `bench/binary-budget-mib`: rebased on `develop` after #1785 raised
the ceiling to 8 MiB

## Related issues

Refs #1759.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Jaro-c added a commit that referenced this pull request Sep 19, 2026
…s up (#1790)

## Summary

- `podup cp` of a directory was reported as failed against Podman 6
whether or not it had landed. Podman 6 applies the archive and closes
without a response, and the only confirmation podup had was one file's
size, which a directory does not have.
- A directory upload is now confirmed entry by entry: every regular file
of the archive that was sent is at the destination with its size, and
every directory is there as a directory.

Refs #1777.

## Root cause

In `put_archive_verified`, after an `IncompleteMessage` on the PUT, the
only arm that could say "landed" was `(Some(path), Some(expected))`.
`uploaded_entry_size` filters on `Metadata::is_file`, so a directory
source gave `None` and fell into `_ => false`, always. On Podman 5 the
PUT answers 200 and the function returns before any of this, which is
why it never showed there.

## Changes

- `internal/engine/copy/verify.rs`, new: `sent_entries` reads the
expectation back out of the gzipped tar that went over the wire, not
from a second walk of the source, so it cannot describe another tree.
`tree_landed` stats each entry with the same archive HEAD the
single-file path uses.
- Anything short of a full match is a failure: an archive that cannot be
read back, one with nothing to ask about, a stat that errors, an entry
that is absent, a file of another size, a file where a directory was
sent.
- Links are sent and not asked about. Measured against Podman 5.7.0 on
2026-09-18: a dangling symlink answers the stat with 404 although it is
there, so asking would turn a landed copy into a failure.
- Entries are asked about last to first, because extraction is
sequential and a cut stream loses its tail, and one at a time, because
Podman 6 dropped responses under concurrency (#1339) and a dropped stat
here is the same false failure over again.
- The single-file path keeps its function, its comparison and its arm.
On a 200 nothing new runs.
- `FakeReply::Headers` in the fake Podman, because the archive stat
lives in a response header and the fake could not send one.

Cost: one HEAD per entry, about 10 ms each measured locally, paid only
after a dropped response. The one request that lists a destination
recursively is the archive GET, which streams the contents back too and
so costs what was already there rather than what was sent.

Residual false positive, the same one the single-file check documents: a
failed upload over a tree whose entries already had these sizes.

## Correction after a second review

A second review found three gaps, fixed in the third commit:

- A FIFO of size 0 passed as an empty regular file, because a file only
had to be "not a directory". It now needs none of Go's `os.ModeType`
bits.
- Links were left out of the expectation because a dangling one answers
404, so an archive cut after its last directory could be confirmed. On
Podman 5.7.0 that 404 still carries the stat header, so links are now
asked about through a separate client method that reads it;
`head_path_stat` keeps treating 404 as absent for its other callers.
Hard links, FIFOs and devices are still not asked about, and the module
says so.
- `to_string_lossy` rewrote a non-UTF-8 name before the stat. Such a
tree is now unverifiable, reported as a failure.

The live test copies a relative link and a dangling one too and reads
them back with `readlink`. Sabotage on the fix: "not a directory" put
back fells the FIFO cases, a link always accepted fells 3, the ordinary
HEAD for links fells the 404-with-stat case, lossy names fell the
non-UTF-8 case.

## Test plan

- [x] `cargo fmt --all --check` is clean
- [x] `cargo clippy --locked --all-targets --all-features -- -D
warnings` is clean
- [x] `cargo test --locked --lib --all-features engine::copy`: 62
passed. The upload tests drive `put_archive_verified` end to end against
the fake with the PUT dropped the Podman 6 way; the stat JSON is in the
shape captured from Podman 5.7.0
- [x] Written first and watched red: on the unfixed code `upload_tests`
was 6 passed, 2 failed, the two being the directory that landed and the
renamed directory, with the message from the issue
- [x] `cargo test --locked --test engine_integration --features
test-helpers cp_flags` against local Podman 5.7.0: 5 passed. That lane
answers 200 and never reaches the new path, so it proves nothing broke,
not that the fix works
- [x] Podman 6: the first green lane on this pull request proved
nothing, and I wrote that it was the proof, which was wrong. The only
live test that copied a directory,
`engine_cp_uploads_a_directory_holding_a_sparse_file`, stored `cp`'s
result and deliberately never asserted on it, because before this fix a
directory copy against Podman 6 failed closed whatever it did on disk.
That lane was green with or without this change. The second commit adds
`engine_cp_reports_a_directory_copy_as_landed`: a plain directory, no
sparse file, no early return, asserting that the bytes arrived and that
`cp` returned `Ok`; the sparse-directory test asserts the return value
too now. Against my local Podman 5.7.0 the six `cp_flags` tests pass,
which shows the test is sound and nothing else, since Podman 5 answers
200 and never reaches the new path. The lane sets
`PODUP_REQUIRE_PODMAN`, so an unreachable Podman is a failure there and
not a silent pass. The proof is this push's Podman 6 lane, measured on
2026-09-19: `Podman 6.1.2: 206 passed, 0 failed`, 0 ignored, 0 filtered
out, where the run before this commit had 205. I cannot sabotage it
locally for the same reason I cannot prove it locally, so what stands
behind it is that the same assertion could not be written before this
fix: the test next to it carried a comment saying why
- [x] New or changed checks were verified by deleting the control and
watching them fail, each edit confirmed with `diff -q`:

| Control removed | What fell |
|---|---|
| the arm put back to `_ => false` | only the two directory tests |
| the file size comparison |
`a_directory_whose_entries_do_not_match_what_was_sent_is_a_failure` and
the unit test of `entry_landed` |
| an empty expectation counted as landed | only
`an_upload_with_nothing_to_ask_about_stays_unconfirmed` |
| a failed stat skipped instead of failing | only
`a_directory_with_an_entry_that_cannot_be_read_back_is_a_failure` |

## Checklist

- [x] Targets `develop`
- [x] Commits are signed off (DCO, `git commit -s`)
- [x] Commits are signed
- [x] Labels applied
- [x] No secrets, keys or credentials in code, logs or fixtures
- [x] Line limit: `copy.rs` goes from 323 to 328 code lines, already
over the 300 warning before this and under 500; the new code lives in
`copy/verify.rs` (96)
- [x] `bench/binary-budget-mib`: rebased on `develop` after #1785 raised
the ceiling to 8 MiB

## Related issues

Refs #1777.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Jaro-c added a commit that referenced this pull request Sep 19, 2026
…1791)

## Summary

- `podup cp SERVICE:PATH HOSTPATH` refused a destination that is a
symlink, but looked at the last component only. `symlink_metadata` is
`lstat`: it does not follow the last component and follows every earlier
one, so `real/link/out` was classified as whatever `elsewhere/out` is
and the bytes landed outside the path I named.
- The guard now walks the destination one component at a time and
refuses a symlink at any of them.

Refs #1764.

## What I found beyond the issue

- A second bypass of the old check: a trailing separator.
`lstat("link/")` follows `link`, so `podup cp svc:/x ./link/` passed the
last-component check too. The walk rebuilds each prefix from
`components()`, which drops it.
- Who can plant the link is not only a local user. `extract_tar_guarded`
creates symlink entries from the archive, it only refuses to write
through them in the same extraction. A hostile container can leave
`backup/data -> ~/.ssh` in one copy and wait for my next `cp svc:/y
./backup/data/keys`.

## Changes

- `internal/engine/copy/destination.rs`, new: `destination_refusal` and
`refusal_for`. Both gates call it, `cp_destination_kind` (routing,
`copy.rs`) and `extract_archive` (buffered branch, `copy/archive.rs`).
- `..` is never collapsed lexically; every prefix before it was already
seen to be a real directory, so the kernel's physical `..` is safe. The
walk stops at the first missing component or at a regular file in the
middle, and the existing "no such directory" error still comes from the
extraction. A component that cannot be inspected for any other reason is
refused.
- The message is unchanged when the link is the destination itself, and
names the offending component otherwise.
- No `cfg` in the guard. On Windows std reports junctions as symlinks
too; I read that in std's source and did not run it on a Windows host.
- `docs/security-model.md`, row "Container filesystem": one sentence
saying what the guard does and that it is a path check, not a pinned
directory handle.

## What this does not close

The time-of-check/time-of-use gap. The guard is a path check and every
later operation walks the path again, so the issue's acceptance line
"resolved once and the result is used" is not met, and the module doc
says so. Who can win that race: a local process that can write in an
ancestor of the destination, and a container with a writable bind mount
over an ancestor of it. Closing it means replacing the extractor with
one that works from an `O_NOFOLLOW` directory handle
(`tar::Entry::unpack_in` takes a path), plus a Windows equivalent. That
is its own issue.

## The system's own links

Refusing every symlink refuses the system's too: `/tmp` on macOS (`/tmp
-> /private/tmp`) and `/home` on Fedora Atomic (`/home -> var/home`).
The first commit of this branch did exactly that, and `podup cp svc:/x
/tmp/out` stopped working there. The second commit adds one narrow
exception. A symlink is let through, and the walk continues below it
with the path as written, only when all of these hold:

- it sits directly in the root directory;
- it is owned by root;
- the root directory is owned by root and has no group or other write
bit.

A container cannot plant a link directly in `/` unless I already
extracted into `/` as root, and then there is nothing left to protect.
This is deliberately not the broader "trust any root-owned link in a
root-owned directory": when podup runs as root, links extracted from a
hostile archive are root-owned inside root-owned directories, and that
rule would trust them. Off Unix the exception does not exist and every
symlink is refused.

Two things came out of reviewing it. `podup cp svc:/x /tmp`, the link
being the destination itself, was still refused because both gates
looked at the last component again on their own; they now read it
through one function, `destination_metadata`, that follows a link only
under the same three conditions. And with a single trusted uid in the
policy the two owner checks could not be told apart in a test, since a
test cannot `chown`: deleting the link-owner check left every test
green. The policy now carries `root_uid` and `link_uid` separately, both
0 in production, so each check falls alone.

## Correction after a second review

A second review found that the exception decided "directly in the root"
by comparing spellings: `/../tmp/out`, or `tmp/out` from a working
directory of `/`, were refused while `/tmp/out` passed, although all
name the same link. Fail-closed, so a false refusal. The third commit
compares canonicalised directories. Three new tests cover
`R/sub/../link`, `R/real/../link` and a relative path; with the
comparison put back to spellings exactly those three fall. The bare
relative case from `/` (an empty parent) is handled but not tested,
because a test cannot change the process's working directory without
racing the others.

## Test plan

- [x] `cargo fmt --all --check` is clean
- [x] `cargo clippy --locked --all-targets --all-features -- -D
warnings` is clean
- [x] `cargo test --locked --lib --all-features engine::copy`: 77
passed, 12 in `copy/destination_tests.rs` and 21 in
`copy/destination_trusted_tests.rs`, on real temp trees with real
symlinks. The exception is tested with a temp directory as the trusted
root and the test process's own uid, since nobody can create a link in
`/` without being root
- [x] Written first and watched red: on the unfixed code 3 passed and 7
failed. The table test counts: of 7 destinations that cross a symlink
the old guard refused 1
- [x] New or changed checks were verified by deleting the control and
watching them fail, each edit confirmed with `diff -q`:

| Control removed | What fell |
|---|---|
| the walk cut back to the last component only | 6: first component,
intermediate, both extraction cases, the table, the message. The
last-component tests stay green |
| an uninspectable component accepted | only
`a_component_that_cannot_be_inspected_is_refused` |
| "directly in the root" not required | 4: the link one level down (walk
and metadata), the table, the walk below a trusted link |
| the link's owner not checked | only the two `link_uid` tests |
| the root's owner not checked | only the two `root_uid` tests |
| the root's mode not checked | only
`a_trusted_root_link_in_a_group_writable_root_is_refused` |
| the exception removed altogether | 3: the link let through, the table,
the walk below it |
| `destination_metadata` following any symlink | the two tests that
expect an untrusted link to still read as a link |

- [ ] Windows: a cross `cargo check` needs a mingw C compiler I do not
have installed. This pull request's Windows job is the proof that it
compiles there

## Checklist

- [x] Targets `develop`
- [x] Commits are signed off (DCO, `git commit -s`)
- [x] Commits are signed
- [x] Labels applied
- [x] No secrets, keys or credentials in code, logs or fixtures
- [x] Docs updated (`docs/security-model.md`)
- [x] `bench/binary-budget-mib`: rebased on `develop` after #1785 raised
the ceiling to 8 MiB

## Related issues

Refs #1764.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ci Subsystem: ci area:update Subsystem: update effort:XS Extra small prio:P2 Medium priority type:deps Dependency update type:security Security-relevant change

Development

Successfully merging this pull request may close these issues.

1 participant