Re-derive three teardown justifications, and sanitize a cmdline on screen (refs #345, refs #348, refs #31, refs #344) - #362
Merged
Merged
Conversation
…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
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.
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.gosaid "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 writingns_last_pid: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
ESRCHrather 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 thestarttimeand namespace-inode checks are for.reap.goandteardown.goalready 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.gojustified identity-by-path with the wrapper case: apodmanforwarding to the host, engine parented to the host's systemd, process tree a lie. Closed since Tier C, two independent ways:Specresolves--root,--runrootand the socket throughEngine.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.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.
TestEngineArgvNamesOnlyGuestPathsmakes the first half checkable — every path-shaped argv element under/snug, andspec.Sockstill the host path because P1'swaitForSocketand the container proxy dial it. Positive control asserts the argv carries at least three paths, so it cannot pass on aSpecthat stopped resolving anything.#31 — step 4 contradicted its own code 210 lines below
inengine.godescribed 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::257setns into the sandbox's mount namespace,:268unshare,:278MS_REC|MS_PRIVATE,:292-312move_mount every graft andMOUNT_ATTR_RDONLYwhere it isAccessRO.Step 4 is now those four calls in order (4a–4d), states that
p.Graftsis 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 throughpolicy.VisibleTextnow.TestNoSnugScreenEmitsARawControlCharacterdrives the--dry-runscreen and does not reachinternal/engine, soTestDescribeSanitisesACommandLinedoes, 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:
/bin/sleep "30<ESC>[1A…"sleeprejects the duration, the decoy becomes a zombie, and a zombie's cmdline reads back empty — the positive control caught itsh -cWorking 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, sostopLockedstep 3'swaitQuietreturnsnilon 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:stopLockedruns from live Go code with the engine still up by its own insistence,excludeholds only snug's pid and the reaper's, and the engine then exits on its ownidleTimeout(10s) inside aquietBudgetof 15s. Every clean@podman-socketrun 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
excludedoes 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, andreap.go's accident sentence now records the measured mismatch and points at #344 rather than reading as though the matcher were correct.Gate
make gategreen — 12 packages, no failures.make integrationnot run: no engine can start in this container (no/etc/subuidrange, nopasta,/usr/bin/podmanis the distrobox shim), and the same failures reproduce onmain. Nothing here touches an integration surface that could run.