Skip to content

release: 5.9.3 - #1780

Merged
Jaro-c merged 2 commits into
mainfrom
develop
Sep 11, 2026
Merged

Jaro-c merged 2 commits into
mainfrom
develop

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 11, 2026

Copy link
Copy Markdown
Member

Merge commit, no tag in this pull request. Two commits past 5.9.2: one fix and its version bump.

podup wrote GNU sparse tar entries, which Podman refuses, so one file with holes on disk took down the whole transfer it travelled in. A build died with unhandled tar header type 83, a cp with unrecognized Typeflag S, and a watch sync logged a warning and silently never delivered the file, exit code 0 and all. The rejection is per archive rather than per entry, so ordinary files beside the sparse one were lost with it.

Nobody puts a sparse file in a build context deliberately. They arrive as database files, disk images, virtual machine state or anything preallocated with fallocate, and ls shows nothing unusual, so the failure read as arbitrary. Podman's own client packs in Go and never emits the type, which is why podman build accepted a context podup build rejected.

Not a regression: every release from 0.3.0 onwards carried it, on Linux, Android and FreeBSD hosts, which are the three targets where the tar crate looks for holes at all.

The fix routes all four tar::Builder construction sites through one constructor that turns sparse off, with a test gate keeping it the only one, and four tests against a live Podman covering build, cp as a file and as a directory, and the watch sync. Each was verified red by reverting the fix.

All three files that stamp a version read 5.9.3 on origin/develop, checked immediately before this was opened. The tag goes on main after this merges, which is the only thing that triggers a release.

…es (#1776)

Closes #1775.

A file with holes on disk went out as a GNU sparse entry, typeflag `S`,
decimal 83, because `tar::Builder::new` enables sparse mode by default.
Podman has no case for that type, so it drops the whole archive on
account of one file. The build endpoint says `unhandled tar header type
83`; the container archive endpoint that `cp` and the watch sync PUT to
says `unrecognized Typeflag S`.

## What I changed

The four places that constructed a `tar::Builder` now go through one
constructor, `engine::tar_stream::builder`, which turns the flag off.
Turning it off four times would have fixed today and left the fifth site
free to be written the old way, so `tests/tar_builder_single_site.rs`
keeps it the only one. Those four sites sat in three unrelated files and
were wrong the same way precisely because each was written on its own.

`follow_symlinks` is untouched at all three sites that set it, which
matters: two of them set it to `false` on purpose so a symlink in the
tree is stored as a link instead of packing the bytes of its target.

## Verification

Every row below is a real run on this machine, podman 5.7.0, released
`v5.9.2` against this branch's binary, same input, and in every case the
control is the same bytes with the hole filled in (`cp --sparse=never`).
Fixtures were checked for a real hole (`stat -c '%s %b'`) before each
run, on tmpfs and again on ext4.

| Path | Case | v5.9.2 | This branch |
| --- | --- | --- | --- |
| build | context holding an 8 MiB sparse file | `unhandled tar header
type 83`, exit 1 | builds, exit 0 |
| cp | single file | `unrecognized Typeflag S`, exit 1 | copies, exit 0
|
| cp | directory holding a sparse file and a plain one | `unrecognized
Typeflag S`, exit 1, and neither file arrives | both arrive |
| watch | initial sync | warning only, exit 0, `/app` empty | `synced`,
both files present |
| watch | live event | warning only, exit 0, `/app` empty | `synced`,
file present |

The `watch` rows are the reason this is worth a gate. A failed sync is a
`warn!` and the loop is meant to survive one, so the exit code stays 0,
the session keeps saying it is watching, and the file just never
arrives. Verified by `ls` and `sha256sum` inside the container rather
than by exit code.

No corruption from expanding the holes: every destination `sha256sum`
equals the host's, and every byte count matches, on all five transfers.

Two things the runs turned up that were not in the issue. The rejection
is per archive, so a sparse file takes its ordinary neighbours down with
it. And `cp` of a directory creates the destination before the stream is
refused, leaving an empty directory that reads as success to anything
checking only for the path.

## Cost

Measured through the same `GzEncoder` the real paths use, on 1 GiB of
holes with four bytes of data: 125 gzipped bytes become 1043015, and
peak RSS moves from 2832 KiB to 2852. `io::copy` writes through a fixed
stack buffer into the encoder, so the streaming build path still never
holds the archive. About 1 MiB on the wire per GiB of holes, and the
reading and gzipping of that GiB, which is the real cost.

## Tests

- `our_builder_never_writes_a_gnu_sparse_entry` packs a real sparse file
through the constructor and asserts no entry is `GNUSparse`. Reverting
`.sparse(false)` makes it fail; I checked, rather than assuming.
-
`the_crate_default_emits_the_type_podman_refuses_when_the_hole_is_real`
is the positive control. Without it the test above is vacuous wherever
the crate never looks for holes, and would stay green with the fix
reverted while reporting that it had checked something. The crate only
looks on Android, FreeBSD and Linux, so the control is gated on exactly
that list rather than on `cfg(unix)`: macOS is Unix and APFS really does
leave the range unallocated, so a block-count check would have failed
the control on the macOS runner for a reason unrelated to this fix.
- `the_expanded_entry_carries_every_byte_the_file_had` reads the archive
back and checks the hole comes out as zeroes with the tail intact.
- `tests/tar_builder_single_site.rs` refuses a second construction site.
It rejects both spellings: the qualified `tar::Builder::new(`, and an
import of `tar::Builder` that would let a bare `Builder::new(` slip past
a literal search. I sabotaged it with each spelling and watched it go
red for each.

Full suite green: 2671 tests, 0 failures, plus `cargo fmt --check` and
`cargo clippy --all-features --all-targets` with nothing emitted.

## Scope

podup only. The five other Rust repositories do not carry the `tar`
crate even transitively, and there is no `.sparse(` call anywhere else
in the tree.

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
One fix landed since 5.9.2 and no published binary carries it, so it
goes out as a patch.

#1776 stopped podup writing GNU sparse tar entries, which Podman
refuses. A file with holes anywhere in a build context, a `cp` payload
or a watched tree took the whole transfer down with it: `unhandled tar
header type 83` on a build, `unrecognized Typeflag S` on a `cp`, and on
a watch sync a warning and silent non-delivery. Every release from 0.3.0
onwards carried it on Linux, Android and FreeBSD hosts.

All three files that stamp a version move together, `Cargo.toml`,
`debian/changelog` and `Cargo.lock`, because the release workflow
refuses to build when they disagree. The reason that check exists is
1.9.0, which shipped `podup_1.8.0_*.deb` when only `Cargo.toml` had been
touched, so apt saw the version it already had and reported nothing to
upgrade.

The changelog entry names the three messages a user would have seen. The
whole difficulty of this defect was that the failure read as arbitrary,
so the wording a person will search for is the useful part of the entry.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c Jaro-c added type:chore Maintenance with no product impact release labels Sep 11, 2026
@Jaro-c
Jaro-c merged commit dd90455 into main Sep 11, 2026
81 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release type:chore Maintenance with no product impact

Development

Successfully merging this pull request may close these issues.

1 participant