Skip to content

release: 5.9.6 - #1876

Merged
Jaro-c merged 25 commits into
mainfrom
develop
Sep 22, 2026
Merged

Jaro-c merged 25 commits into
mainfrom
develop

Conversation

@Jaro-c

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

Copy link
Copy Markdown
Member

Release 5.9.6: the 24 commits on develop since 5.9.5. The user-visible changes are in the debian/changelog entry added in #1875.

Headline items:

It also carries the branch-health fix (#1853) to main, whose push run has been red since 5.9.5 for that reason alone.

…f ignoring them (#1847)

Closes #1840.

## The defect

`ps --services --format json` printed plain text and exited 0. A script
doing `--format json | jq` received `w`, which is not JSON, and the exit
code said everything went fine. `ps -q --services` did the same: `-q`
asks for container IDs and got a service name.

What made it inconsistent rather than merely undocumented is that the
documented pair refused correctly. `-q --format` errored at parse time
with exit 2 while the other two combinations were accepted and dropped,
so from outside there was no way to tell which flags were honoured.

## The control

The test enumerates the combinations rather than spot-checking one,
because a test that only covered the documented pair would have passed
while the bug stood. Measured on the built binary:

| Combination | Before | After |
|---|---|---|
| `-q --format json` | exit 2 | exit 2 |
| `--services --format json` | exit 0, plain text | **exit 2** |
| `--services -q` | exit 0, service name | **exit 2** |
| `--services` alone | exit 0, `w` | exit 0, `w` |
| `--format json` alone | exit 0, JSON | exit 0, JSON |
| `-q` alone | exit 0, ids | exit 0, ids |

The last three are in the test so the fix could not be a blunt "reject
anything with `--services`". `--format` carries `default_value_t`, so it
was worth measuring rather than assuming that a default does not trigger
the conflict: it does not.

## Sabotage

My first attempt was inert and the reason is worth recording: **clap
conflicts are symmetric.** Removing `services_only` from `quiet`'s list
left `services_only`'s own list enforcing the same pair, so all 21 tests
still passed and nothing had actually been sabotaged.

Removing it from **both** sides restores the defect (`ps --services
--format json` back to exit 0) and drops exactly one assertion,
`ps_output_flags_conflict_in_every_pair_and_survive_alone`, with the
other 20 green.

Gates, all rc=0: `cargo test --locked --all-features --test cli_flags`
(21 passed), `cargo test --locked --lib --all-features`, `cargo fmt
--all --check`, `cargo clippy --locked --all-targets --all-features --
-D warnings`.

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

## The defect

`ps` printed the container name; fourteen subcommands that take a target
accept the **service** name and reject it. The table handed the reader
the one identifier none of them takes, and never showed the one they
want.

```
$ podup -f c.yml -p nmtest ps
NAME         IMAGE
nmtest-web-1 docker.io/library/alpine:latest

$ podup -f c.yml -p nmtest logs nmtest-web-1
podup: error: service 'nmtest-web-1' not found
```

## Why this is a display change and nothing more

The data was already there. `ps --format json` returned `"Service":
"web"` in every row, and `podup images` already printed a `SERVICE`
column. `ps` was the outlier among podup's own commands, not only
against `docker compose ps`.

```
$ podup -f c.yml -p v37 ps
NAME      IMAGE                           SERVICE CREATED                STATUS
v37-web-1 docker.io/library/alpine:latest web     Less than a second ago Up 0s
```

## What is deliberately not in it

No subcommand was widened to accept a container name. `docker compose
logs <container>` does not work either, so accepting it would be a
deliberate divergence rather than compatibility, and that decision has
not been taken.

That also narrows the invariant in #1846: "every identifier printed is
accepted" is stronger than the reference implementation. What holds, and
what this test asserts, is that the row carries **a** name the
target-taking commands accept.

## Sabotage

Making the `SERVICE` cell print the container name, which is the
original defect, drops exactly
`ps_service_column_value_round_trips_through_ps_and_logs` and leaves the
other 11 green.

## Collateral, reported rather than silently edited

`tests/engine_integration/cli_lifecycle.rs` sliced the row by column
index (`cells[2..up]`) to reconstruct the CREATED cell. Adding a column
between IMAGE and CREATED moves that boundary, so the slice and its
comment change with it. `status_col` moved from 3 to 4 for the same
reason.

Gates, all rc=0: `cargo test --locked --all-features --test
output_contract` (12 passed), `cargo test --locked --lib --all-features`
(2028 passed), `cargo fmt --all --check`, `cargo clippy --locked
--all-targets --all-features -- -D warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…gin (#1849)

Closes #1841.

## The defect

`secret_in_environment` inspected the value **after** interpolation, so
the same file passed the gate when a variable was unset and failed when
it was exported:

```
$ env -u SECRETO podup -f z.yml audit --strict   -> no findings, exit 0
$ SECRETO=valor  podup -f z.yml audit --strict   -> finding,     exit 1
```

A gate whose verdict depends on the caller's shell is not a gate: the
same commit passes on a laptop and fails in CI, and the compose files
are identical.

## The decision

Flag it always, whatever the origin. The risk is the same in both
shapes: the value reaches the container's environment whether it was
written literally or injected through `${VAR}`.

## Measured after the change

Each row run twice, once with the variable exported and once with it
unset, counting `secret_in_environment` lines:

| Input | unset | exported | Stable |
|---|---|---|---|
| `DB_PASSWORD: literal-secreto` | 2 | 2 | yes |
| `DB_PASSWORD: ${SEC}` | 2 | 2 | **yes**, was 0 / 2 |
| `EMPTY_PASS: ""` | 0 | 0 | exemption kept |
| `PASSTHROUGH: "true"` | 0 | 0 | exemption kept |
| `DB_PASSWORD:` (bare) | 0 | 0 | exemption kept |

The counts are 2 rather than 1 because of #1838, which doubles every
diagnostic and is being fixed separately. The property this PR is about
is the column on the right.

## The message

It said `DB_PASSWORD carries a hard-coded value`, which was false for a
file containing `${SEC}`. A reader who opened the file to find the
hard-coded value would not find one, which costs the check its
credibility on the cases where it is right.

It now reads `DB_PASSWORD is set in compose; move it to secrets:`, true
for both origins. `hard-coded` no longer appears anywhere in the output.

## Sabotage

Restoring the exemption for an unresolved `${VAR}` drops exactly
`audit_secret_in_environment_verdict_is_independent_of_var_export` and
leaves the other 6 green.

## On #1833

`POSTGRES_PASSWORD_FILE: /run/secrets/...` has the `PASSWORD` segment
and a non-empty value, so it is flagged before and after. That is a
different exemption, it belongs to #1833, and this change neither fixes
nor worsens it.

Gates, all rc=0: `cargo test --locked --all-features --test
audit_exit_codes` (7 passed), `cargo test --locked --lib --all-features`
(2026 passed), `cargo fmt --all --check`, `cargo clippy --locked
--all-targets --all-features -- -D warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Refs #1844. Fixes the compression half; the issue stays open for the
memory half.

## The change

`cp` built its tar inside a `GzEncoder` at `Compression::default()` and
handed the result to a Unix socket on the same machine. The archive is
plain tar now.

## Measured, 220 MB file, alternating order across rounds

| | `podman cp` | `podup cp` |
|---|---|---|
| before | 234-248 ms | 4998-5173 ms |
| after | 231-501 ms | **483-495 ms** |

From roughly 21x to roughly parity. One round came in under podman.

## What did not change, and is reported rather than glossed

Peak RSS is **473960 KB**, against 456052 KB before and podman's 48216
KB. Removing the encoder's output buffer did not move it, which is the
expected result: the archive is still built whole in memory before
anything is sent.

That ceiling is deliberately out of scope here. Streaming is a much
larger change to the same code path that #1808 is still unresolved in,
and mixing the two is how a diagnosis gets lost. **#1844 stays open for
the memory half**, and now carries the measurement.

## The coupling this surfaced

`sent_entries` read the archive back through `flate2::read::GzDecoder`.
A writer that stops compressing and a reader that keeps decompressing do
not meet, so `verify.rs` and its tests move with `archive.rs`. That is
the path #1777 and #1808 live in, so it was checked rather than assumed.

## Correctness

- sha256 of the 220 MB file on the host and inside the container after
the copy: identical.
- The eight live `cp_flags` tests pass against a real Podman 5.7.0, with
`PODUP_REQUIRE_PODMAN=1` so a skip would have failed instead of passing
quietly.

## Sabotage

My first attempt did not compile, so it proved nothing and I discarded
it. A sabotage that re-gzips the finished tar, and does compile, drops
five tests: `pack_path_archive_is_not_gzip_compressed` by name, plus
four in `upload_tests` that fail because the reader no longer
decompresses. That second group is the coupling above, caught by the
suite rather than by review.

The shape assertion reads the first two bytes for the gzip magic `0x1f
0x8b` rather than trusting the constructor, so it survives a refactor
that changes how the encoder is built.

Gates, all rc=0: `cargo test --locked --lib --all-features` (2027
passed), the live `cp_flags` (8 passed), `cargo fmt --all --check`,
`cargo clippy --locked --all-targets --all-features -- -D warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…t carries it (#1853)

Closes #1831. P0: this makes the 5.9.5 release run on `main` red and
fails both checks on every pull request opened since.

## The defect

The script asked for `workflows/${workflow}/runs?branch=${branch}`,
filtered by `head_sha`, sorted by `created_at` and took the newest. It
did not filter by event.

A release pull request goes from `develop` into `main`, so its head
branch is `develop` and its run appears under `?branch=develop` beside
the push run for the same commit. Being newer, it wins. For `develop`'s
head `20937cc`:

| Run | Event | Conclusion |
|---|---|---|
| #2539 | `pull_request` | failure |
| #2538 | `push` | **success** |

The script read #2539. That run is #1830, the release pull request, and
its `ci.yml` concluded failure **because this very job failed inside
it**. The gate read its own run, failed, made that run red, and every
later reader saw a red the gate created. #1809 was opened to close
exactly this loop in its other form.

## Why it is not cosmetic

On run 35538230895, the push run for `83315af` on `main` and the 5.9.5
release commit, `branch health (develop)` is the **only** failing job.
`main`'s history records the release as red, about the state of a branch
that was green, as reported by a run that branch did not trigger.

## The fix

Both the runs call and the jq filter select `event=push`. A branch's
health is the verdict of the run that branch triggered; a `pull_request`
run that merely carries the branch as its head is a different question.

Nothing else moved: the polling bound, the cancelled-is-red rule and the
`HEAD_SHA` gating from #1827 are unchanged. Three defects in a row have
come out of this file and widening the change is how a fourth arrives.

## Sabotage

Removing the filter from **both** places, because removing one leaves
the other enforcing it, drops four assertions:

```
FAIL  newer pull_request failure cannot shadow older push success for head
      (gate must not read a run the branch did not trigger): exit 0
FAIL  verdict line names run #60 (the push run, not the pull_request run #62)
FAIL  verdict line does NOT name run #62 (the pull_request run)
FAIL  pull_request run URL #62 must NOT appear
```

The suite also carries the assertion that stops the lazy fix: a **newer
push failure** over an older push success still fails, so this cannot
become "prefer whatever succeeded".

Gates, all rc=0: `bash tests/shell/branch-health-conclusion.test.sh` (59
passed), `cargo test --locked --test 'workflow_*'` (33 passed), `cargo
fmt --all --check`, `shellcheck` on the script and its test,
`actionlint` on the reusable.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…rop it (#1851)

Closes #1839.

## The decision, taken by the owner

1. `--no-warn` silences the port-publishing warning. Publishing on
`0.0.0.0` is host binding, which the flag's documentation already
promised to cover.
2. Read-only commands stop emitting it. `config` keeps it and stays
exempt from the flag.

## Measured on the built binary

| Command | without the flag | with `--no-warn` |
|---|---|---|
| `up -d` | 1 | **0** |
| `ps` | **0** | 0 |
| `logs w` | **0** | 0 |
| `port w 80` | **0** | 0 |
| `top w` | **0** | 0 |
| `config` | 1 | 1 |

Before this change every row except `config` read 1 / 1.

## What is deliberately unchanged

`privileged has reduced effect under rootless Podman` is an
informational note about what rootless does, not a host-binding warning,
so it stays outside the flag: two warnings without it, one with it,
exactly as before. Widening `--no-warn` to cover it is a separate
decision nobody has taken.

## Why read-only commands and not just the flag

`podup port w 80` on a service publishing 18080 and 18081 warned about
**18080**, which is not the port that was asked about. A command that
starts nothing has no bind to confirm, and there was no way to silence
it there because the flag's scope never reached those commands.

## Sabotage

| Sabotage | Assertions that fell |
|---|---|
| `--no-warn` stops reaching `up` |
`up_no_warn_silences_the_port_exposure_warning` |
| read-only commands warn again | the three `logs`/`port`/`top`
assertions |

`tests/host_binding_warnings.rs` already mentioned `--no-warn` thirty
times but only covered `config` and `generate quadlet`, which is why
nothing caught this. It now covers `up` and the read-only commands too.

Gates, all rc=0: `cargo test --locked --all-features --test
host_binding_warnings` (17 passed), `cargo test --locked --lib
--all-features` (2029 passed), `cargo fmt --all --check`, `cargo clippy
--locked --all-targets --all-features -- -D warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…pass (#1852)

Closes #1838.

## The defect

A single unset variable referenced once produced two warnings, and four
under `audit`. The multiplier was not per-variable or per-occurrence:
the compose document is interpolated more than once per command, once
for the diagnostics pass that detects keys the typed model drops and
again for the real render, and every pass warned.

## Measured, counting `variable is not set` lines

| Input | `config` | `audit` |
|---|---|---|
| one variable once | 2 -> **1** | 4 -> **1** |
| two different variables | 4 -> **2** | 8 -> **2** |
| same variable three times | 6 -> **1** | -> **1** |

## The second question, answered deliberately

The issue left open whether three references to the same unset variable
should warn three times or once. It warns **once**: it is one piece of
missing information, not three, and that matches docker compose
reporting once per variable per file. The reasoning is in the code
rather than only here.

## The assertions are counts

A `contains` assertion passed at two warnings and would have passed at
ten. That is exactly why nothing caught this, so the tests assert exact
numbers.

## Sabotage, and a measurement error worth recording

My first sabotage removed the guard in `nested_raw.rs` and **all 19
tests still passed**. There are two guards, one there and one in
`compose/mod.rs`, so removing one leaves the other silencing the pass:
nothing had actually been sabotaged. Same shape as the clap-conflict
symmetry in #1847.

Removing **both** drops exactly four assertions, each named for the
property:

- `unset_variable_once_warns_once_on_config_and_audit`
- `bare_unset_variable_once_warns_once_on_config_and_audit`
- `two_unset_variables_warn_twice_on_config_and_audit`
- `same_unset_variable_three_times_warns_once_on_config_and_audit`

I also nearly sent this back as incomplete. My first reading measured
`audit` at 3 rather than 1, because `grep "is not set"` also matches two
audit findings whose own text contains that phrase (`pids_limit is not
set`, `userns_mode is not set`). The fix was complete; my pattern was
too loose.

Gates, all rc=0: `cargo test --locked --all-features --test
cli_diagnostics` (19 passed), `cargo test --locked --lib --all-features`
(2026 passed), `cargo fmt --all --check`, `cargo clippy --locked
--all-targets --all-features -- -D warnings`.

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…or (#1854)

Closes #1842.

## The defect

Deleting a watched file printed

```
podup: warning: watch action failed: build error: No such file or directory (os error 2) when getting metadata for /tmp/d5/./src/f.txt
```

and left the file inside the container. The rule's action is `sync`, so
`build error` is the wrong category; nothing failed, a file was deleted;
and `docs/commands.md` says *"Read the warnings, not the status"*, which
makes that line the only signal the design leaves open.

## Measured by hand on the built binary

| Check | Result |
|---|---|
| file deleted on the host | **gone** from the container |
| target directory `/app` | **survives** |
| lines containing `build error` | **0** |
| path with a literal `./` | **0** (`/tmp/v42/src/f.txt`) |
| new message | `removed /app/f.txt from v42-a-1` |

The scope is bounded and the doc table now says so: only entries inside
the rule's target, never the target directory itself.

## The test was blind, and that is the part worth reading

The first version of this change **passed with the feature disabled**. I
turned `if is_remove_event(...)` into `if false &&
is_remove_event(...)`, rebuilt, and with the real binary the file stayed
inside the container with zero `removed` lines. The behaviour was gone.
The live test still passed, three runs in a row.

The reason was in the test's own comment:

```rust
// Second: delete the file on the host and run the removal flow that the
// live dispatch runs on a Remove event.
let remove_outcome = engine.test_remove_from_container(...).await;
```

It said it ran the flow the live dispatch runs, and it called the leaf
directly. `is_remove_event`, the event plumbing and the path mapping
were all skipped, so the test passed whatever that path did. The same
shape as the gap #1825 was opened for, where `userns_tests.rs` called
`create_and_start` and never created a pod.

The test now starts the watcher the way a user does. The identical
sabotage fails it:

```
thread 'watch_delete::watch_sync_propagates_host_deletions_to_the_container' panicked:
the host-side deletion did not propagate; /app/f.txt is still present in the container
```

Gates, all rc=0: the live test three times in a row (11.11s, 10.71s,
10.80s) with `PODUP_REQUIRE_PODMAN=1`, `cargo test --locked --lib
--all-features` (2032 passed), `cargo fmt --all --check`, `cargo clippy
--locked --all-targets --all-features -- -D warnings`, `RUSTDOCFLAGS="-D
warnings" cargo doc --no-deps --all-features`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…t of the environment (#1855)

Closes #1833.

## The defect, as reported

`podup audit` reported `secret_in_environment` for
`POSTGRES_PASSWORD_FILE: /run/secrets/<name>`, which is the pattern that
**removes** the secret from the environment. The check flagged the
correct configuration, so the only way to get a clean audit was to stop
using the convention the official Postgres, MariaDB and MySQL images
document. The reporter runs `audit --strict` as a deployment gate and
had to carve the finding class out by hand.

## The rule, and why this one

The exemption is **the `_FILE` suffix on the key**, not the
`/run/secrets/` prefix on the value.

The value is a path to a file the application reads at runtime. A path
is not a secret, so the key carrying it is not a secret in the
environment. That holds wherever the path points, which is why the check
trusts the convention rather than verifying the destination: a `_FILE`
key pointing at a custom mount, or anywhere else, is still a path.
Verifying what the path points at, its permissions or its provenance, is
a different audit concern.

Matching on the last segment rather than a literal `_FILE` keeps it
robust across the spellings in use: `POSTGRES_PASSWORD_FILE`,
`MY_KEY_FILE`, `KEY-FILE`, `KeyFile`.

## Measured, each row run twice

| Input | unset | exported |
|---|---|---|
| `POSTGRES_PASSWORD_FILE: /run/secrets/x` | not flagged | not flagged |
| `PASSWORD_FILE: /etc/passwd` | not flagged | not flagged |
| `DB_PASSWORD: literal` | flagged | flagged |
| `DB_PASSWORD: ${SEC}` | flagged | flagged |

The last row is #1841, which landed earlier today: the verdict must not
depend on whether the variable is exported. It does not.

## Sabotage

Disabling the exemption drops exactly the two assertions named for it:
`..._does_not_flag_file_suffix_pointing_at_secrets_mount` and
`..._does_not_flag_file_suffix_pointing_outside_secrets_mount`. The
second one exists because the decision to trust the convention rather
than the path is the debatable half, so it is pinned rather than
implied.

Gates, all rc=0: `cargo test --locked --all-features --test
audit_exit_codes` (9 passed), `cargo test --locked --lib --all-features`
(2032 passed), `cargo fmt --all --check`, `cargo clippy --locked
--all-targets --all-features -- -D warnings`, `RUSTDOCFLAGS="-D
warnings" cargo doc --no-deps --all-features`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…wered (#1857)

Refs #1808. This does not fix that issue; it makes the next attempt
start from a measurement.

## Why

The error a caller saw was

```
the upload to /tmp could not be confirmed: the container runtime closed the
connection without a response and the destination did not change
```

which names nothing anyone can check. The entry that actually failed was
reported through `tracing::debug!`, and the integration suite's env
filter does not emit debug, so the Podman 6 job log carried no trace of
it. I grepped run 35526977642 for the entry name, the stat and the
warning text and found nothing.

That cost two rounds on #1808. Both attempts at the Podman 6 failure
were made without knowing which of the archive's five entries was
refused (`a.txt`, an empty file, a symlink, a dangling symlink, the
directory) or what the runtime had answered about it. It was never even
established that the link was the failing entry.

## What changed

The error names the path, what was expected of it, and what the stat
said:

```
payload/link in /tmp is not what was uploaded; expected Link("a.txt"), runtime answered PathStat { ... }
```

Better for a user too: the old message told an operator that something
they cannot see did not match something they cannot name.

## What did NOT change, and how that is evidenced

No confirmation rule moved. `entry_landed`, `LinkCheck`, and the mode
and size comparisons are untouched: the diff contains no line touching
`LinkCheck::`, `is_symlink`, `is_regular`, `is_dir`, `stat.size` or
`link_target`. The existing confirmation tests pass unchanged, which is
the evidence rather than the claim.

## Sabotage

Reverting the message to a generic one drops four assertions, each named
for what it protects:

- `the_refusal_names_the_entry_path`
- `the_refusal_carries_the_stat_that_was_read`
- `the_refusal_for_a_404_without_stat_explains_the_missing_stat`
- `a_refused_entry_message_names_the_path_and_the_stat`

Gates, all rc=0: `cargo test --locked --lib --all-features` (2038
passed), the live `cp_flags` with `PODUP_REQUIRE_PODMAN=1` (8 passed),
`cargo fmt --all --check`, `cargo clippy --locked --all-targets
--all-features -- -D warnings`, `RUSTDOCFLAGS="-D warnings" cargo doc
--no-deps --all-features`.

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

Closes #1832.

## The decision

Refuse, unless the compose file declares `external: true`. The project
name is the isolation boundary; a project that wants to share a network
says so.

## Measured on the built binary

Two projects, `p1` and `p2`, both declaring `name: red-compartida`:

| | Result |
|---|---|
| `p2 up` without `external` | **rc=1**, `network 'red-compartida'
already exists and is labelled podup...` |
| `p2 up` with `external: true` | rc=0, attaches |
| `p2 down` afterwards | `p1`'s network **survives** |

The teardown half is the one that loses data. With the owner's
containers stopped libpod would honour the DELETE, so the guard fires
before the request lands rather than relying on the removal failing.

## The part that goes beyond the issue, and deserves your eye

**An unlabelled network is refused too.** A network created by hand, or
by another tool, now needs `external: true`:

```
$ podman network create red-ajena
$ podup -f p3.yml up -d
podup: error: unsupported feature: network 'red-ajena' already exists and carries no podup label
```

The reasoning in the code is that the label is the only ownership
evidence there is, and "no one owns it" is not a safe state to delete
from. That is defensible and it is consistent, but the reported case was
cross-project adoption, not hand-made networks. Anyone with `name:
some-existing-network` and no `external: true` breaks on upgrade, and
that is worth a deliberate yes rather than arriving as a side effect.

`external: true` does work for that case, measured: rc=0.

## Sabotage

Disabling the ownership comparison in the teardown path drops exactly
`down_refuses_to_remove_a_foreign_labelled_network`, with the other
three green.

My first attempt did not compile, so it proved nothing and I discarded
it rather than reading its silence as a passing control.

Gates, all rc=0: the live `network_ownership` suite three times in a row
(4 passed each, 21.04s / 21.40s / 20.83s) with `PODUP_REQUIRE_PODMAN=1`,
`cargo test --locked --lib --all-features` (2038 passed), `cargo fmt
--all --check`, `cargo clippy --locked --all-targets --all-features --
-D warnings`, `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
--all-features`.

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Refs #1846. Writes four of the five; the fifth belongs to the harness
and stays open.

## Why these rather than eight tests

Eight defects were found in one afternoon by running the published
binary against a real Podman, all of them shipping and none known.
Writing eight assertions fixes eight bugs. The four below are stated as
properties over the surface, so each covers a family.

| Invariant | What it asserts |
|---|---|
| round trip | every row a listing prints carries at least one
identifier the target-taking commands accept, read from the rows rather
than hardcoded |
| count, not match | diagnostics assert an exact number of lines |
| ambient independence | the same input gives the same verdict whatever
the caller's environment |
| accept or refuse | every pair of output-shaping flags parses or
errors; none is accepted with a flag dropped |

## They hold today, so the question is whether they bite

All four pass on `develop`, because the fixes that landed this afternoon
closed them. A test that passes on a fixed tree proves nothing by
itself, so I reintroduced two of today's defects:

**#1840, flags silently dropped.** Two assertions fail, and the second
is the point of this change:

```
ps_output_flags_conflict_in_every_pair_and_survive_alone      <- the specific one from #1847
every_command_with_output_flags_rejects_or_parses_every_pair  <- the family one
```

**#1841, audit's verdict depending on the environment.**
`audit_secret_in_environment_verdict_is_independent_of_var_export`
fails.

## A correction carried into the code

The round-trip invariant as first written on the issue said "every
identifier a command prints is accepted". That is stronger than the
reference implementation: `docker compose ps` prints a container name
and `docker compose logs <container>` rejects it, so demanding it would
require a divergence rather than a fix. The wording here is the one that
holds and still catches a future column that prints something nothing
accepts.

Gates, all rc=0: the four suites (8 + 20 + 22 + 13 = 63 passed), `cargo
test --locked --lib --all-features` (2032 passed), `cargo fmt --all
--check`, `cargo clippy --locked --all-targets --all-features -- -D
warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…drift (#1859)

Closes #1836.

## What it does

```
$ podup audit --list-checks
privileged            privileged: true grants extended host privileges.
host_namespace        a host-binding namespace mode shares the host's or another container's namespace.
dangerous_capability  cap_add carries a capability from the dangerous list...
...
```

Eleven checks, text or `--format json`, exit 0, no compose file needed.
Measured in an empty directory.

## The identifiers are the contract

Drift detection is worthless if the listing invents a second vocabulary,
so the ids are the strings findings actually carry. Measured: an audit
of a dirty compose emitted 9 ids, and all 9 are in the listing.

The test asserts **both directions**, which is more than the issue asked
for and is the part worth keeping:

- every id the audit emits is named by the registry, so a check function
whose hardcoded id drifts is caught;
- every registry entry fires on the dirty fixture, so an entry whose
check stopped emitting its id is caught too.

## It does not repeat #1840

`--list-checks --strict` conflicts at parse time with exit 2 rather than
accepting the flag and ignoring it.

## Sabotage, after two of mine that proved nothing

Renaming the emitted `unpinned_image` id fails the integration suite.
Recording the two attempts that did not, because the lesson is mine
rather than the code's:

- the first deleted the registry line and **did not compile**, so its
silence meant nothing;
- the second renamed one of two emitter branches, and my fixture
exercised the other, so the drift I thought I had planted was never
reachable.

Gates, all rc=0: `cargo test --locked --all-features --test
audit_list_checks --test audit_exit_codes` (7 + 4 passed), `cargo test
--locked --lib --all-features` (2032 passed), `cargo fmt --all --check`,
`cargo clippy --locked --all-targets --all-features -- -D warnings`,
`RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
`podup cp` opened a progress row and left it on a static `Copying` until
the copy landed. A multi-gigabyte directory looked the same as a hung
socket for the whole transfer.

Both directions now feed a shared byte counter as data flows: the
container→host reader counts what it drains out of the archive stream,
the host→container PUT counts body chunks as they go out. A task reads
the counter every 100 ms and rewrites the verb through the same
`progress::start` path `up`, `pull` and `build` use, so a tty repaints
in place while a redirected stderr collapses to the one line the plain
sink prints at `end`. The closing verb carries the final total.

**Sabotage, measured.** Three rounds, because the first two passed on
shape alone:

| Change | Tests that fail |
|---|---|
| emitter verb drops the byte figure | 2 |
| `ByteCounter::add` stops accumulating | 5 |
| the stream reader adds `0` instead of `n` | 1 — and **0 before this
PR's last assertion existed** |

The third one is the point. Every verb stays well-formed when the
producer stops counting: `Copied 0B` starts with `Copied `, is longer
than `Copied`, and passes every shape assertion. The piped-cp test now
reads the closing figure as a quantity against the 256 KiB fixture, so
the producer side is pinned and not just the frame.

Closes #1845

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

`entry_landed` confirmed an uploaded symlink as soon as the destination
was a symlink, whatever it pointed at. A copy cut mid-stream over a tree
with a same-named link pointing elsewhere was reported as landed.

**What Podman actually sends**, measured on the socket with `curl -I`
before writing the fix (Podman 5.7.0):

| link | points at | `size` | `linkTarget` |
|---|---|---|---|
| `/tmp/link-a` | `/etc/hostname` | 13 | `/etc/hostname` |
| `/tmp/d/rel` | `../etc/hosts` | 12 | `/tmp/etc/hosts` |
| `/tmp/d/dangling` | `nothing-here` | 12 | `/tmp/d/nothing-here` |

`linkTarget` is normalised: a relative target is joined to the link's
directory and `..` is resolved lexically. A literal comparison against
the text in the tar would refuse **every relative link** and fail every
directory copy containing one, so the sent side is normalised the same
way first. `size` is the length of the link *text*, so it is compared as
well, with no normalisation needed.

A runtime that sends no `linkTarget` falls back to the type check as
`LinkCheck::Fallback`, distinct from `Confirmed` in the log.

The normaliser splits on `/` by hand rather than via `Path::components`,
which splits on `\` on Windows: a container path is POSIX on every host,
and the platform path type would have given the Linux and Windows lanes
different verdicts. A test pins `a\b` as one component; it is the
Windows lane that exercises it.

**Regular files:** size plus mode-type, unchanged. No checksum in the
stat, and Podman 6 reports mtime to whole seconds (already recorded on
`PathStat`).

**Sabotage:** target comparison forced true, 3 tests fail. Normalisation
returning its input, 6 fail, three of them through the upload path.

Closes #1808

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…ct (#1864)

Nine comments across the tree explained code by pointing at a document
that is not in the repository: "the brief calls this out", "the cases
the brief names", "when the brief was written". Someone opening the file
learns nothing from them. A review checklist that searched for the word
missed all nine, so this moves the rule from the checklist into CI.

- Every occurrence is rewritten to say what the code does or what the
test pins. Seven files, comments only.
- `.github/scripts/check-planning-words.sh` fails on the whole word,
case-insensitive, in any tracked file except `CHANGELOG.md`, and
annotates file and line.
- It runs as a step in **`Format & lint`**, which is already a required
check. A new job would report and block nothing until a ruleset named
it.
- `tests/shell/check-planning-words.test.sh` runs the real script in a
throwaway repo, reading the exit status directly: clean tree passes;
planted comment fails with file and line; case ignored; `briefly`
passes; changelog exempt; untracked file not read. Wired into
`lint-shell.yml`'s test command and both path filters.

**Sabotage:** dropping `-w` fails the `briefly` case; dropping the `exit
1` fails the two cases that must be red.

Open PRs are unaffected: none of them touches the lines rewritten here,
so their merge refs come out clean.

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
`podup audit` had no opinion on port exposure, while `up` and `config`
have warned about it since the port-exposure diagnostic landed. An
operator running the audit as a deployment gate got a pass on a compose
file that publishes Postgres on every interface.

The check reads the same predicate the diagnostic does rather than
restating it: `ports_published_on_all_interfaces` is now the single
source of truth in `internal/compose/diagnostics/ignored_fields.rs`, and
both surfaces format their own message from the `PortExposure` it
returns. A future compose shape that counts as published reaches the
audit and the warning together.

Thresholds are the diagnostic's, unchanged. An explicit host IP is not
flagged, including `0.0.0.0` and a private LAN address; a container-only
short form is not a publish; a range emits one finding with the range
string as its label.

One behaviour changes in the diagnostic: `":5432"` used to emit `port is
published on every interface`, with the label missing. It is now
skipped, and a test pins both halves.

**Sabotage, measured:** emptying the shared predicate stops the binary
emitting the finding (`podup audit` on a `5432:5432` fixture goes from
one `port_published_on_all_interfaces` line to zero) and fails six of
the sixteen new audit tests plus four of the diagnostic's. Removing the
empty-host guard fails the test that pins it.

Note on running them: the audit tests live in the binary target, so
`cargo test --lib` does not include them. `cargo test` with no target
flag does.

Closes #1835

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
`podup audit` had no opinion on bind mounts. A compose file that mounts
the container runtime socket into a service — which hands it the ability
to start sibling containers as the daemon's user — passed `--strict`
silently.

`sensitive_bind_mount` fires on:
- **the runtime socket**, matched exactly, with stronger wording (the
file is the attack);
- **a short list of host directories**, matched as a prefix so a subpath
is the same finding: `/proc`, `/sys`, `/dev`, `/etc`, `/boot`, `/root`,
and the runtime directories that hold a socket.

`:ro` still fires. Relative paths and the project directory do not.

**The rootless socket** is matched by shape:
`/run/user/<uid>/podman/podman.sock` is the one a rootless operator has
and the one podup connects to by default, and the uid varies. The first
version missed it while its comment called `/run/podman/podman.sock` the
rootless socket — that is the system socket, and the comment now says
so. The uid must be all digits; nothing else under `/run/user/` is
flagged (session bus, audio, display sockets are mounted on purpose by
desktop containers).

| Mount | Findings |
|---|---|
| `/run/podman/podman.sock` | 1 |
| `/var/run/docker.sock` | 1 |
| `/run/user/1000/podman/podman.sock` | 1 |
| `/run/user/1000/pulse/native` | 0 |

Measured on the built binary.

Built on top of #1859's registry: the check lives in its own
`check_sensitive_bind.rs` and has a `CHECK_REGISTRY` entry. Both dirty
fixtures now plant a rootless socket, because #1859's test requires
every registry entry to fire. It did its job here: without the fixture
line the test failed and named the new id.

**Sabotage:** rootless matcher returning nothing fails both rootless
tests and the registry test; dropping the all-digits uid rule fails the
test that pins what is left alone.

Closes #1834

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
`podup up` created the project network before it validated the services,
so an `up` rejected for `pid: "evil"` or `ipc: "bogus"` left
`<project>_default` on the host. Reproduced on 5.9.5 in #1867.

`run_up` now runs `pre_validate_spec` for every enabled service before
creating any network, volume, secret, pod or container. Same function
`create_and_start` already called per service, not a second rule set.

To run it that early, the validator can no longer take the device access
strings the spec builder computes, since building them stats host nodes.
It reads them through the engine's own parsers:
`parse_device_cgroup_rule` as it is (no filesystem access), and
`device_spec_parts`, the `host:container:access` split moved out of
`parse_device` so both call one function. A rule the engine drops as
malformed (`c 1:3 rwx extra`) is not validated either.

**One thing worth checking in review:** the two live tests in
`error_surfacing.rs` list networks **before** the `down` that cleans up.
Running `down` first, which is what the first version of these
assertions did, removes the network being asserted on and passes whether
or not the defect is fixed.

**Sabotage** (`--no-fail-fast`):

| Change | Fails |
|---|---|
| remove the up-front loop | 3 fake-socket tests + both live tests
(Podman 5.7.0) |
| validate only the first service | the two-service test |
| `device_spec_parts` drops the access | the `devices:` rejection test +
2 `parse_device` tests |

This also unblocks #1860: the integration leak scan found this defect as
two of its seven leaks.

Closes #1867

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

pin-watch opened #1869: the `debian:trixie` image both .deb builds pin
by digest had moved upstream. Dependabot cannot bump it, since the
`github-actions` ecosystem does not touch a workflow's `container:`
image.

- Both pins move together, `release.yml`'s container and the
`reusable-rust-debian.yml` default: `tests/workflow_debian_image.rs`
requires them equal.
- The value is the OCI **image index** digest, read from the registry
rather than copied from the issue, so the pin stays multi-arch:
  ```
  content-type: application/vnd.oci.image.index.v1+json
docker-content-digest:
sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
  ```
- Two comments beside the pins described a disagreement between them
that no longer exists; they now say what enforces the agreement.

`tests/workflow_debian_image.rs`: 3/3.

Closes #1869

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
The integration suite left resources on the host on every run and
nothing noticed: 125 containers, 189 networks and several hundred images
had piled up on one machine, and the containers alone held 58 of the 128
inotify instances a user may open (#1843).

**Why a separate binary.** The first version of this PR scanned from
inside `engine_integration` and failed on `podman-vm (5)`: cargo runs
one binary's tests in parallel, so a `z_`-named scanner reported two
resources of a test that finished after it. The scan now lives in
`integration_leak_scan`, run as a second `cargo test` process once the
suite has exited, whether it passed or not. `engine_integration` hands
over its PID through `target/podup-leak-scan-pid`.

**The lane fails** when the scan reports a leak, when its marker is
missing, and when the PID file was never written (`rc=nopid`), since the
scan exits cleanly without a PID and would otherwise pass having scanned
nothing. I drove the verify snippet with all four marker shapes before
pushing.

**Guarding the scanner:** one test plants a network under its own prefix
and requires the scanner to name it; another drives `podman` into a
non-zero exit and requires that to fail the scan rather than read as an
empty list. Emptying the scanner or making it swallow the exit each
fails one of them.

**What it found, and what is fixed here:** seven leaks on its first
local run. Two were a podup defect, fixed in #1868. Five were tests that
never tore down; they now bind a `DownGuard` that runs `down -v` on drop
and prints the error if `down` fails. Two build tests hold a `TestImage`
guard.

**Measured:** the whole live suite, 218/218 on Podman 5.7.0, then the
scan with that run's PID: nothing. A manual listing of containers,
networks, volumes, pods and images with the run's prefix was empty.

Closes #1843

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
The Podman 5 VM already moved container logs to `k8s-file` because
Fedora 44's journald binding cannot open its library (every logs request
came back `500 unable to open a handle to the library`). The events
backend reads the same journal through the same binding and was left on
journald.

`niche::engine_events_stream_connects` failed on that leg three runs in
a row on #1871, with this, once #1871 made the test print what it got:

```
Err(Podman(Api { status: 500, message: "unable to open a handle to the library" }))
```

The logs error word for word. This sets `events_logger = "file"` next to
the existing `log_driver = "k8s-file"`, for both legs like the log
driver.

**What this does not explain:** the same test passed on the Podman 5 leg
of other pull requests between those three runs (#1860, #1870). I don't
know why it is consistent on #1871 only. This removes the journal
dependency the error names; it does not claim to explain the pattern.

The lane runs on this PR because it edits its own workflow file.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…ce (#1871)

podup labelled containers and networks with `podup.project` but not the
images it built, so `podman image prune --filter
label=podup.project=<p>` matched nothing. In production that is about
1.3 GB per deploy of a multi-stage build, reclaimable only by an
unfiltered prune that also takes other projects' images (#1866).

Every build now sends `podup.project` and `podup.service` in `labels`
(final image) **and** as a repeated `layerLabel=<key>=<value>` (every
intermediate stage). Measured on the Podman 5.7.0 socket, three-stage
build:

| query | new images | labelled |
|---|---|---|
| `labels` only | 4 | 1 |
| + `layerLabel` | 4 | **4** |

podup's keys go in after the user's, as the container path already does,
so a user `podup.project` cannot put this project's images in reach of
another project's prune.

**Visible side effect:** with labels always present, buildah adds a
LABEL step, so a two-instruction Containerfile reports `STEP 1/3`.
`tests/build_contract.rs` pins it.

**Sabotage** (`--no-fail-fast`):

| Change | Fails |
|---|---|
| drop `layerLabel` | 3 query tests + the live test |
| `layerLabel` without `podup.project` | the live test, naming the
unlabelled image |
| podup's labels before the user's | the override test |

The live test diffs every image id before and after the build, instead
of selecting by the label it is checking. Its drop guard lists ids by
label and then removes them (`podman rmi` has no `--filter`).

Closes #1866

---------

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

`podup cp` host to container built the whole tar into a `Vec<u8>` before
sending it: 437 MB of RSS for a 220 MB copy, and a copy larger than free
memory failed (#1844).

The packer now runs on a blocking thread and writes into a bounded
channel whose receiver is the PUT body. Measured with the release binary
against Podman 5.7.0, sha256 checked inside the container after every
copy:

| source | before | after |
|---|---|---|
| 220 MB | 0.48–0.55 s, **437 MB** RSS | 0.13–0.16 s, **10.6 MB** RSS |
| 1 GB | — | 0.58–0.59 s, **10.2–10.6 MB** RSS |

RSS does not grow with the file.

**Confirmation without re-reading the archive.** The landed-check used
to walk the tar it had just sent; a streamed body is gone after the PUT.
The packer now records each entry as it appends it. There is **one walk
per path**: `pack_path` (cp) and `build_sync_tar` (watch sync) each take
a writer and a recorder, and production and tests call the same
function, with a channel writer or a `Vec<u8>`. A test requires the
recorded list to equal what `sent_entries` reads back from the bytes
written.

**`cp -L`** follows the link through that recorder and records the
target's kind, so the confirmation asks for what was sent. An earlier
version of this branch lost `-L`; only the live test caught it, and
there are now packer tests for link and tree, with and without `-L`.

**Sabotage** (`--no-fail-fast`): symlinks skipped in the archive but
recorded, 4 fail; dropped from the recorder, 5; mid-pack error
swallowed, 2; `follow_link` ignored, 2. Live: `cp` group 12/12, `watch`
group 10/10.

**Not a regression, recorded separately:** the live `watch` group is
flaky under heavy parallelism on `develop` too (7/10 at 16 threads on
both trees, 10/10 at 4 on both). I am opening an issue for it rather
than changing the watch loop here.

Closes #1844

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Bumps the three version stamps together, as `release.yml` requires:
`Cargo.toml`, `Cargo.lock` and a new `debian/changelog` entry covering
the 24 commits since 5.9.5.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c
Jaro-c merged commit 234b809 into main Sep 22, 2026
85 of 88 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant