Skip to content

Re-derive three teardown justifications, and sanitize a cmdline on screen (refs #345, refs #348, refs #31, refs #344) - #362

Merged
vyskocilm merged 3 commits into
mainfrom
lane-b-teardown
Aug 22, 2026
Merged

Re-derive three teardown justifications, and sanitize a cmdline on screen (refs #345, refs #348, refs #31, refs #344)#362
vyskocilm merged 3 commits into
mainfrom
lane-b-teardown

Conversation

@vyskocilm

Copy link
Copy Markdown
Contributor

refs #345, refs #348, refs #31, refs #344

Four comments describing something other than what the code does, plus the half of #344 that needs no decision.

#344's matcher is NOT fixed here, and the reason is a design question rather than a missing engine — see the last section.

#345 — the pidfd claim, measured rather than restated

orphansweep.go said "while the fd is held the kernel cannot recycle that pid number… there is no residual pid-reuse window at all". Measured false on this host, in a fresh user+pid namespace writing ns_last_pid:

pinned pid           = 7 (reaped, pidfd still open)
fdinfo says          = Pid: -1  NSpid: -1
next child got pid   = 7  (collision=true)
signal through pin   = no such process
new occupant alive   = true

The number recycles. What the pin guarantees is the other half, and it is the half this sweep needs: the pidfd refers to the task it opened, dies with it, and returns ESRCH rather than reaching the new occupant — #303's central claim, confirmed by the same run. That is what makes the kill fail closed.

The comment now says that, and says what follows from it: anything reading /proc/<pid> by number after the open may be describing a different task, which is exactly what the starttime and namespace-inode checks are for. reap.go and teardown.go already word it this way.

Why it was worth correcting with no exploitable consequence today: the false version is the licence a later edit needs to act on the number inside that window — a kill(2) fallback, a cgroup lookup, a /proc/<pid> write.

#348 — the wrapper justification, re-derived

reap.go justified identity-by-path with the wrapper case: a podman forwarding to the host, engine parented to the host's systemd, process tree a lie. Closed since Tier C, two independent ways:

  • The argv names guest paths only. Spec resolves --root, --runroot and the socket through Engine.guestPath, which refuses a path no graft exposes, so the argv is entirely under /snug. Those paths resolve only inside the engine's derived mount namespace — an engine that exec'd on the host could not open its own store or bind its own socket, so it cannot start.
  • Preflight P1 refuses the shim by name, before that. Measured on this host: "podman resolves to /usr/bin/podman, a host-escape helper (distrobox-host-exec) … snug will not run the container engine through it."

Path identity stays, for the reason that holds now: it verifies the cascade (the engine is pid 1 of its own namespace, so the collapse fells it and every container), and a recorded pid is not an option because libpod's are numbered in the engine's own namespace (#167).

A wrapper that re-execs inside the derived view is still possible and is not that case: it is a descendant in the engine's pid namespace, so the cascade already covers it. Named, per the issue's step 3.

TestEngineArgvNamesOnlyGuestPaths makes the first half checkable — every path-shaped argv element under /snug, and spec.Sock still the host path because P1's waitForSocket and the container proxy dial it. Positive control asserts the argv carries at least three paths, so it cannot pass on a Spec that stopped resolving anything.

#31 — step 4 contradicted its own code 210 lines below

inengine.go described step 4 as "a plain private COPY of the host tree, deliberately NOT derived from the resolved Policy — that is Tier C's later pieces". The body: :257 setns into the sandbox's mount namespace, :268 unshare, :278 MS_REC|MS_PRIVATE, :292-312 move_mount every graft and MOUNT_ATTR_RDONLY where it is AccessRO.

Step 4 is now those four calls in order (4a–4d), states that p.Grafts is their author (invariant 6), and keeps the distinction the stale text got right: the view is not the container bind filter, and neither may be read as the other. Sub-lettered rather than renumbered, so nothing downstream of it shifts.

Same statement at enginefork.go's #146 inventory — "no derived mount VIEW, no grafts — those are issue #125's later pieces" — now says C0 is the precondition for the view rather than a piece that shipped without it.

#344, partial — the sink that needs no decision

describe() rendered a matched process's command line with only NUL replaced. A command line is not snug's text: any process can put anything in its own argv, so an ESC there erases the line snug printed above it and a bidi override reverses the order the rest reads in. It goes through policy.VisibleText now.

TestNoSnugScreenEmitsARawControlCharacter drives the --dry-run screen and does not reach internal/engine, so TestDescribeSanitisesACommandLine does, with a live decoy. Mutation observed: without the sanitiser, describe emitted '\x1b' raw, from a command line snug does not own.

That test's two false starts are worth carrying, because both are harness traps #344 names:

attempt what happened
/bin/sleep "30<ESC>[1A…" sleep rejects the duration, the decoy becomes a zombie, and a zombie's cmdline reads back empty — the positive control caught it
single-command sh -c exec-optimised, crafted argv gone from the cmdline

Working decoy: sh -c 'sleep 30; :' <crafted argv>. The empty-cmdline window (#318) is polled past rather than raced — the control caught that too, on the first run.

#344's matcher: handed back, and it is not the engine

Feasibility checked before starting, as asked. The core defect is measurable here without podman and the ratchet for it is pure. What blocks it is a design decision:

paths() returns the host socket; the engine's cmdline names the guest one, so stopLocked step 3's waitQuiet returns nil on its first poll every run and the SIGKILL fallback and the WARNING screen are unreachable. But swapping in the guest path makes the matcher match a process that is supposed to be alive: stopLocked runs from live Go code with the engine still up by its own insistence, exclude holds only snug's pid and the reaper's, and the engine then exits on its own idleTimeout (10s) inside a quietBudget of 15s. Every clean @podman-socket run would sit there — the 15.3s the issue measured with a decoy.

So step 3 has to learn either that the engine is expected alive (which needs an engine pid exclude does not have) or that its verification belongs where the cascade actually happens (the reaper path, after snug's process dies). That is a decision about what teardown verifies and when, and #348's re-derivation above is the argument that it is the second — but it is not mine to make. Nothing in this PR presumes an answer, and reap.go's accident sentence now records the measured mismatch and points at #344 rather than reading as though the matcher were correct.

Gate

make gate green — 12 packages, no failures. make integration not run: no engine can start in this container (no /etc/subuid range, no pasta, /usr/bin/podman is the distrobox shim), and the same failures reproduce on main. Nothing here touches an integration surface that could run.

…reen (refs #345, refs #348, refs #31, refs #344)

Four comments that describe something other than what the code does, plus the
half of #344 that needs no decision. #344's MATCHER is NOT fixed here — see
below.

#345 — orphansweep.go claimed "while the fd is held the kernel cannot recycle
that pid number, so there is no residual pid-reuse window at all". MEASURED
false on this kernel, in a fresh user+pid namespace writing ns_last_pid:

  pinned pid           = 7 (reaped, pidfd still open)
  fdinfo says          = Pid: -1  NSpid: -1
  next child got pid   = 7  (collision=true)
  signal through pin   = no such process
  new occupant alive   = true

The NUMBER recycles. What the pin guarantees is the other half and it is the
half the sweep needs: the pidfd refers to the task it opened, dies with it, and
returns ESRCH rather than reaching the new occupant (#303, confirmed by the same
run). That is what makes the kill fail CLOSED. The comment now says so, and says
what follows: anything reading /proc/<pid> BY NUMBER after the open may be
describing a different task, which is what the starttime and namespace-inode
checks are for. The false version was the licence a later edit needs to act on
the number inside that window.

#348 — reap.go justified identity-by-path with the WRAPPER case: a podman
forwarding to the host, engine parented to the host's systemd. Closed since Tier
C, two independent ways. The argv names guest paths only — Spec resolves --root,
--runroot and the socket through guestPath, which refuses a path no graft
exposes — and those resolve only inside the derived mount namespace, so an
engine exec'd on the host cannot open its store or bind its socket. Preflight P1
refuses the shim before that, by name, measured on this host.

Path identity stays, for the reason that holds NOW: it verifies the cascade, and
a recorded pid is not an option because libpod's are numbered in the engine's own
namespace (#167). A wrapper re-execing INSIDE the derived view is still possible
and is not that case — it is a descendant in the engine's pid namespace, so the
cascade covers it.

TestEngineArgvNamesOnlyGuestPaths makes the first half checkable: every
path-shaped argv element is under /snug, and spec.Sock is still the HOST path
because P1's waitForSocket and the proxy dial it. Positive control asserts the
argv carries at least three paths, so it cannot pass on a Spec that stopped
resolving.

#31 — inengine.go described step 4 as "a plain private COPY of the host tree,
deliberately NOT derived from the resolved Policy — that is Tier C's later
pieces", contradicted by its own code 210 lines below: setns into the sandbox's
mount namespace, unshare, MS_REC|MS_PRIVATE, then move_mount every graft. Step 4
is now the real four calls in order (4a-4d), states that p.Grafts is their author
(invariant 6), and keeps the distinction the stale text got right: the view is
not the container bind filter, and neither may be read as the other.

Same statement at enginefork.go's #146 inventory — "no derived mount VIEW, no
grafts — those are issue #125's later pieces" — now says C0 is the PRECONDITION
for the view rather than a piece that shipped without it.

#344, PARTIAL. describe() rendered a matched process's command line with only
NUL replaced. A command line is not snug's text, so an ESC there erases the line
snug printed above it; it goes through policy.VisibleText now.
TestNoSnugScreenEmitsARawControlCharacter drives the --dry-run screen and does
not reach internal/engine, so TestDescribeSanitisesACommandLine does, with a
live decoy. Mutation observed: without the sanitiser, "describe emitted '\x1b'
raw".

That test's own two false starts are worth carrying, both being harness traps
#344 names: `/bin/sleep "30<ESC>..."` rejects the duration and the decoy becomes
a ZOMBIE, whose cmdline reads back empty; and a single-command `sh -c` is
exec-optimised and loses the crafted argv. The working decoy is
`sh -c 'sleep 30; :' <argv>`, and the empty-cmdline window (#318) is polled past
rather than raced.

NOT FIXED: paths() still returns the host socket while the engine's cmdline names
the guest one, so stopLocked's step 3 remains a check that cannot fail. Handed
back — correcting the matcher changes what step 3 verifies and when, because
stopLocked runs while the engine is deliberately ALIVE and exclude holds no
engine pid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, refs #307)

PR #360 (7d4a351) gave Engine.Spec a fifth parameter *SignaturePolicy after
this branch merged main. New test reapidentity_test.go called the old
signature:

    vet: internal/engine/reapidentity_test.go:34:54: not enough arguments in call to e.Spec
    	have (*policy.Policy, string, []string, bool)
    	want (*policy.Policy, string, []string, bool, *SignaturePolicy)

Passes noSignaturePolicy(t) — "this host configured none" — same as every
other Spec call in the package. NOT nil: Spec refuses nil outright as a
caller that skipped ProjectHostSignaturePolicy, which is #307's no-fallback
clause.

The value does not change what TestEngineArgvNamesOnlyGuestPaths measures.
The signature policy reaches the engine as a generated
$HOME/.config/containers/policy.json (writeEngineHome), and podman 5.8.4 has
no --signature-policy flag at all (engine.go:1068), so no value of it puts a
path in the argv this test sweeps. Comment states that at the call site.

make gate green.
@vyskocilm
vyskocilm merged commit 9116d07 into main Aug 22, 2026
5 checks passed
@vyskocilm
vyskocilm deleted the lane-b-teardown branch August 22, 2026 21:36
vyskocilm added a commit that referenced this pull request Aug 22, 2026
…344, refs #345)

Two conflicts, both prose, both in paragraphs #362 and this branch edited for
opposite reasons. Neither side taken wholesale.

reap.go, conflict 1 -- the accident sentence. #362 describes the matcher as
UNFIXED and says so outright ("The matcher is not corrected here; #344 carries
it"). This branch corrects it, so HEAD's substance wins: the mark is
/snug/engine/sock/podman-<pid>.sock, a string the PAYLOAD can author, and what
stops that is WHEN the sweep runs, not the matcher. Kept from #362: the host
spelling is /tmp/snug-<uid>-<pid>/sock/podman-<pid>.sock and NOT under
$XDG_RUNTIME_DIR, where an earlier design put it before #63 Tier B moved the
run directory to /tmp. That fact now earns its place for a new reason -- it is
the other half of paths() returning nil rather than falling back to e.sock: no
process carries that string, so a fallback is indistinguishable from not
sweeping.

reap.go, conflict 2 -- describe()'s escaping. HEAD's substance again (the
match is now a payload-authorable string, so escaping stopped being
theoretical), plus #362's naming of WHICH test covers the sink, which HEAD
omitted: TestNoSnugScreenEmitsARawControlCharacter drives the --dry-run screen
and does not reach internal/engine; TestDescribeSanitisesACommandLine does.

container.go -- not a real disagreement. main added `started = true` (disarming
the early-cleanup defer), this branch added the Detach/Stop rationale. Both
kept. The rationale is also CORRECTED by this branch's own measurement: it no
longer says the shipped wiring makes snug wait out the engine's idle timeout,
because that is false -- measured 15ms on both wirings against a real podman
5.8.4. It says what is true instead: Stage.Wait returns on recvEvent (the
bytes ARRIVING), not on P1's exit, so payload-exit is a RACE that P1 merely
happens to win; st.Close ends with cmd.Process.Wait, after which it cannot
lose. A guarantee where there was a race.

Five test call sites updated for main's two signature changes: Spec's fifth
parameter (noSignaturePolicy(t) -- "this host configured none", never nil,
which #307 makes an error naming the skipped ProjectHostSignaturePolicy) and
New now taking a *policy.Policy (testPol(...)).

#345's SECOND COPY, which is the reason this touches orphansweep.go at all.
The doc comment there is correct since #362 -- the number IS recyclable while
the fd is held, measured, with a closing warning that a future edit acting on
the number is not made safe by the pin. Twelve lines below that warning, inside
the very function it governs:

    // pid is pinned from here: the number cannot be recycled while pidfd is
    // held, so the checks below and the kill all name the same task.

The original false claim, in its second location, and exactly the licence the
corrected header withdraws. #270's shape: a rule fixed at the site the reporter
quoted while a pre-existing copy survives. No behavioural consequence today --
the kill goes through the pin and fails closed with ESRCH.

SWEPT THE SET, NOT THE SITE, before fixing. All three files the header claims
"state the same guarantee the same way" were checked. NO THIRD COPY:
  internal/engine/reap.go:195       correct -- "pins the task the number named
                                    AT OPEN TIME, so pidfd_send_signal can
                                    never land on a later reuse"
  internal/sandbox/teardown.go:444  correct -- "pins whatever process holds pid
                                    RIGHT NOW"; names the recycled-occupant
                                    case explicitly and skips rather than kills
So the header's claim about its two neighbours is TRUE, verified rather than
inherited. Same guarantee, not the same words.

The replacement says what the pin does guarantee, and adds the part the old
text obscured: procStartTime and procNamespaceInodes below read /proc/<pid> BY
NUMBER, so identity comes from what they COMPARE against the state file, never
from the pin.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant