Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ UNIT_BATS := test/init-detection.bats test/verify-init-quoting.bats test/ver
test/verify-pin-unit.bats test/verify-hardcap-unit.bats \
test/verify-allowips-rows-unit.bats test/verify-dnsaudit-unit.bats \
test/verify-bump-knobs-unit.bats test/verify-replay-unit.bats \
test/verify-podman-userns-unit.bats
test/verify-podman-userns-unit.bats test/verify-build-hash-unit.bats
ACCEPT_BATS := test/acceptance.bats test/acceptance-bump.bats test/verify-run-default.bats
SECURITY_BATS := $(wildcard test/verify-security-*.bats)
ENGINE_BATS := $(ACCEPT_BATS) $(SECURITY_BATS)
Expand Down
38 changes: 31 additions & 7 deletions bin/sluice
Original file line number Diff line number Diff line change
Expand Up @@ -1654,6 +1654,20 @@ cmd_doctor() {
[ "$_nl" -gt 10 ] && _doc "" "${C_DIM}(+ $((_nl - 10)) more)${C_RST}"
fi

# Rootless podman can't enforce some knobs the way docker does - surface the caveats (see hardening.md).
if [ -n "$eng" ] && _rootless_podman; then
_doc podman "${C_DIM}rootless - pids/memory caps need cgroups-v2 + systemd delegation; needs netfilter modules loaded${C_RST}"
{ [ -n "${SLUICE_PIDS_LIMIT:-}" ] || [ -n "${SLUICE_MEMORY:-}" ]; } && { _attn=$((_attn+1)); _doc "" "${C_YEL}a pids/memory cap is set but may silently not apply under rootless podman${C_RST}"; }
local _dp
set -f # split the ports list on whitespace, not glob it against $PWD
for _dp in ${SLUICE_PORTS:-}; do
set +f
case "$_dp" in ''|*[!0-9]*) ;; *) [ "$_dp" -lt 1024 ] && { _attn=$((_attn+1)); _doc ports "${C_YEL}rootless podman can't publish host port $_dp (<1024)${C_RST}"; } ;; esac
set -f
done
set +f
fi

if [ -n "$eng" ]; then
if _with_timeout 5 "$eng" image inspect "$tag" >/dev/null 2>&1; then
if [ "$(_with_timeout 5 "$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then
Expand Down Expand Up @@ -1951,8 +1965,11 @@ cmd_doctor_json() {
blocked_json="$(printf '%s' "$blocked" | tr ' \t' '\n\n' | _json_arr)"
[ "$egress_unavail" = true ] && blocked_json=null

printf '{"schema":"sluice.doctor/v1","engine":"%s","engine_found":%s,"daemon":%s,"config":"%s","config_error":%s,"project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","policy":%s,"state_dirs":%s,"overlay_dirs":%s,"mounts":%s,"auth":%s,"hardening":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"risk":%s,"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \
"$(_json_esc "$engine_ver")" "$engine_found" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$config_error" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \
# rootless podman can't enforce pids/memory caps or bind ports <1024 the way docker does (additive field).
local rootless_podman=false; [ -n "$eng" ] && _rootless_podman && rootless_podman=true

printf '{"schema":"sluice.doctor/v1","engine":"%s","engine_found":%s,"daemon":%s,"rootless_podman":%s,"config":"%s","config_error":%s,"project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","policy":%s,"state_dirs":%s,"overlay_dirs":%s,"mounts":%s,"auth":%s,"hardening":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"risk":%s,"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \
"$(_json_esc "$engine_ver")" "$engine_found" "$daemon" "$rootless_podman" "$(_json_esc "$PROJECT_CONFIG")" "$config_error" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \
"$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \
"$allow_json" "$(base_domains | tr ' ' '\n' | _json_arr)" \
"$ports_json" "$ips_json" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \
Expand Down Expand Up @@ -1996,7 +2013,10 @@ cmd_ls() {
shift
done

imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | grep -v '^<none>$' | sort -u || true)" # grep exits 1 on no-match; tolerate it so the empty-state branch is reachable under set -e
# sed strips podman's localhost/ prefix so the name matches the canonical sluice-<slug> everywhere
# downstream (ps filter, box_blocked_count, the ${#sluice-} slug); grep exits 1 on no-match, tolerated
# so the empty-state branch stays reachable under set -e.
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | sed 's,^localhost/,,' | grep -v '^<none>$' | sort -u || true)"
if [ -z "$imgs" ]; then
if ! "$ENGINE" info >/dev/null 2>&1; then # binary exists (dispatch checked), so empty == daemon down
[ "$mode" = --json ] && { echo '[]'; return 0; }
Expand Down Expand Up @@ -2250,9 +2270,10 @@ resolve_runner() {
# >= 4.3 (the keep-id uid/gid options); older podman gets a warning + the recovery command. Appends to
# RUNTIME_RUN_OPTS so it composes with the Kata opt. Docker and rootful podman are unaffected (no-op).
# Call from the box-start path only, not the lenient doctor probe (avoids the extra calls + warning).
# True iff $RUNNER is rootless podman (basename-strip so a full path still matches). One `podman info`.
_rootless_podman() { [ "${RUNNER##*/}" = podman ] && [ "$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null)" = true ]; }
resolve_podman_userns() {
[ "${RUNNER##*/}" = podman ] || return 0
[ "$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null)" = true ] || return 0
_rootless_podman || return 0
local v maj min
v="$(podman version --format '{{.Client.Version}}' 2>/dev/null)"
maj="${v%%.*}"; min="${v#*.}"; min="${min%%.*}"
Expand Down Expand Up @@ -2740,7 +2761,7 @@ if [ "${1:-}" = ls ]; then shift; cmd_ls "$@"; exit $?; fi
cmd_prune() {
local only_orphans="" imgs i a proj kept
[ "${1:-}" = --orphans ] && only_orphans=1
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | grep -v '^<none>$' | sort -u || true)"
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | sed 's,^localhost/,,' | grep -v '^<none>$' | sort -u || true)" # strip podman's localhost/ so names match the canonical sluice-<slug>
[ -n "$imgs" ] || { echo "[sluice] no sluice boxes to prune."; return 0; }
if [ -n "$only_orphans" ]; then # keep only boxes whose recorded project dir is gone
kept=""
Expand Down Expand Up @@ -3179,7 +3200,10 @@ build() {
_pin_active=1
fi
# Self-describing labels (read by `sluice ls`; not part of config_hash, so no spurious rebuild).
local args=(--label "sluice.confighash=$(config_hash)"
# Hoist the hash: `local args=($(config_hash))` masks its exit status (local returns 0), so a failing
# config_hash (e.g. shasum rc 127) would silently bake an empty label under set -e; assigned alone it dies.
local _chash; _chash="$(config_hash)"
local args=(--label "sluice.confighash=$_chash"
--label "sluice.project=$PROJECT_DIR"
--label "sluice.stack=$(config_stack)"
--label "sluice.allowcount=$(printf '%s' "${SLUICE_ALLOW_DOMAINS:-}" | wc -w | tr -d ' ')"
Expand Down
14 changes: 14 additions & 0 deletions docs/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,17 @@ to map your host user straight onto the sluice uid: the repo stays writable in t
you on the host. This needs **podman >= 4.3**. On older podman sluice warns and falls back to the plain
chown, which re-owns the repo to a subuid; restore your write access with
`podman unshare chown -R 0:0 "$PWD"`.

## Rootless podman: other caveats

Rootless podman can't do a few things docker does; `sluice doctor` flags the ones your config triggers.

- **Resource caps may not apply.** `--pids-limit` / `SLUICE_MEMORY` enforce only when the host has
cgroups v2 with systemd delegation. Without it podman silently ignores them, so the fork-bomb / RAM
cap is off. Enable [rootless cgroup delegation](https://rootlesscontaine.rs/getting-started/common/cgroup2/)
or run under docker if you rely on those caps.
- **Host ports < 1024 won't bind.** `SLUICE_PORTS` entries below 1024 can't be published rootless
(`sysctl net.ipv4.ip_unprivileged_port_start=<n>` on the host, or map to a high port).
- **Netfilter modules must be loaded.** The in-box firewall uses `xt_owner`, `xt_state`, and
`nat`/`REDIRECT`; a rootless user namespace can't autoload kernel modules, so on a podman-only host
where they aren't already loaded the box fails to boot. Preload them (`modprobe`) once on the host.
26 changes: 23 additions & 3 deletions src/30-doctor-ls.sh
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,20 @@ cmd_doctor() {
[ "$_nl" -gt 10 ] && _doc "" "${C_DIM}(+ $((_nl - 10)) more)${C_RST}"
fi

# Rootless podman can't enforce some knobs the way docker does - surface the caveats (see hardening.md).
if [ -n "$eng" ] && _rootless_podman; then
_doc podman "${C_DIM}rootless - pids/memory caps need cgroups-v2 + systemd delegation; needs netfilter modules loaded${C_RST}"
{ [ -n "${SLUICE_PIDS_LIMIT:-}" ] || [ -n "${SLUICE_MEMORY:-}" ]; } && { _attn=$((_attn+1)); _doc "" "${C_YEL}a pids/memory cap is set but may silently not apply under rootless podman${C_RST}"; }
local _dp
set -f # split the ports list on whitespace, not glob it against $PWD
for _dp in ${SLUICE_PORTS:-}; do
set +f
case "$_dp" in ''|*[!0-9]*) ;; *) [ "$_dp" -lt 1024 ] && { _attn=$((_attn+1)); _doc ports "${C_YEL}rootless podman can't publish host port $_dp (<1024)${C_RST}"; } ;; esac
set -f
done
set +f
fi

if [ -n "$eng" ]; then
if _with_timeout 5 "$eng" image inspect "$tag" >/dev/null 2>&1; then
if [ "$(_with_timeout 5 "$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then
Expand Down Expand Up @@ -736,8 +750,11 @@ cmd_doctor_json() {
blocked_json="$(printf '%s' "$blocked" | tr ' \t' '\n\n' | _json_arr)"
[ "$egress_unavail" = true ] && blocked_json=null

printf '{"schema":"sluice.doctor/v1","engine":"%s","engine_found":%s,"daemon":%s,"config":"%s","config_error":%s,"project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","policy":%s,"state_dirs":%s,"overlay_dirs":%s,"mounts":%s,"auth":%s,"hardening":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"risk":%s,"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \
"$(_json_esc "$engine_ver")" "$engine_found" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$config_error" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \
# rootless podman can't enforce pids/memory caps or bind ports <1024 the way docker does (additive field).
local rootless_podman=false; [ -n "$eng" ] && _rootless_podman && rootless_podman=true

printf '{"schema":"sluice.doctor/v1","engine":"%s","engine_found":%s,"daemon":%s,"rootless_podman":%s,"config":"%s","config_error":%s,"project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","policy":%s,"state_dirs":%s,"overlay_dirs":%s,"mounts":%s,"auth":%s,"hardening":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"risk":%s,"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \
"$(_json_esc "$engine_ver")" "$engine_found" "$daemon" "$rootless_podman" "$(_json_esc "$PROJECT_CONFIG")" "$config_error" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \
"$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \
"$allow_json" "$(base_domains | tr ' ' '\n' | _json_arr)" \
"$ports_json" "$ips_json" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \
Expand Down Expand Up @@ -781,7 +798,10 @@ cmd_ls() {
shift
done

imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | grep -v '^<none>$' | sort -u || true)" # grep exits 1 on no-match; tolerate it so the empty-state branch is reachable under set -e
# sed strips podman's localhost/ prefix so the name matches the canonical sluice-<slug> everywhere
# downstream (ps filter, box_blocked_count, the ${#sluice-} slug); grep exits 1 on no-match, tolerated
# so the empty-state branch stays reachable under set -e.
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | sed 's,^localhost/,,' | grep -v '^<none>$' | sort -u || true)"
if [ -z "$imgs" ]; then
if ! "$ENGINE" info >/dev/null 2>&1; then # binary exists (dispatch checked), so empty == daemon down
[ "$mode" = --json ] && { echo '[]'; return 0; }
Expand Down
5 changes: 3 additions & 2 deletions src/40-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ resolve_runner() {
# >= 4.3 (the keep-id uid/gid options); older podman gets a warning + the recovery command. Appends to
# RUNTIME_RUN_OPTS so it composes with the Kata opt. Docker and rootful podman are unaffected (no-op).
# Call from the box-start path only, not the lenient doctor probe (avoids the extra calls + warning).
# True iff $RUNNER is rootless podman (basename-strip so a full path still matches). One `podman info`.
_rootless_podman() { [ "${RUNNER##*/}" = podman ] && [ "$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null)" = true ]; }
resolve_podman_userns() {
[ "${RUNNER##*/}" = podman ] || return 0
[ "$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null)" = true ] || return 0
_rootless_podman || return 0
local v maj min
v="$(podman version --format '{{.Client.Version}}' 2>/dev/null)"
maj="${v%%.*}"; min="${v#*.}"; min="${min%%.*}"
Expand Down
2 changes: 1 addition & 1 deletion src/60-main-flow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ if [ "${1:-}" = ls ]; then shift; cmd_ls "$@"; exit $?; fi
cmd_prune() {
local only_orphans="" imgs i a proj kept
[ "${1:-}" = --orphans ] && only_orphans=1
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | grep -v '^<none>$' | sort -u || true)"
imgs="$("$ENGINE" image ls --filter label=sluice.confighash --format '{{.Repository}}' 2>/dev/null | sed 's,^localhost/,,' | grep -v '^<none>$' | sort -u || true)" # strip podman's localhost/ so names match the canonical sluice-<slug>
[ -n "$imgs" ] || { echo "[sluice] no sluice boxes to prune."; return 0; }
if [ -n "$only_orphans" ]; then # keep only boxes whose recorded project dir is gone
kept=""
Expand Down
5 changes: 4 additions & 1 deletion src/70-build-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ build() {
_pin_active=1
fi
# Self-describing labels (read by `sluice ls`; not part of config_hash, so no spurious rebuild).
local args=(--label "sluice.confighash=$(config_hash)"
# Hoist the hash: `local args=($(config_hash))` masks its exit status (local returns 0), so a failing
# config_hash (e.g. shasum rc 127) would silently bake an empty label under set -e; assigned alone it dies.
local _chash; _chash="$(config_hash)"
local args=(--label "sluice.confighash=$_chash"
--label "sluice.project=$PROJECT_DIR"
--label "sluice.stack=$(config_stack)"
--label "sluice.allowcount=$(printf '%s' "${SLUICE_ALLOW_DOMAINS:-}" | wc -w | tr -d ' ')"
Expand Down
32 changes: 32 additions & 0 deletions test/verify-build-hash-unit.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bats
# build() must NOT mask a config_hash failure. `local args=(--label ...$(config_hash)...)` returns
# local's own 0 status, so a failing hash (e.g. shasum rc 127) would bake an EMPTY sluice.confighash
# label under set -e - after which every maybe_build sees a mismatch and rebuilds silently. Hoisted onto
# its own assignment, the failure propagates and the build dies. (unit; no engine.)
load test_helper/common

setup() {
local src; src="$(cd "$BATS_TEST_DIRNAME/.." && pwd)/src"
BIN="${src%/src}/bin/sluice" # capture before sourcing: the prelude re-derives ROOT from $0
SRC="$src"
}

@test "build-hash: a failing config_hash aborts the build under set -e (no empty-label bake)" {
local core; core="$(mktemp -d)"; : > "$core/Dockerfile"
local pdir; pdir="$(mktemp -d)"; : > "$pdir/sluice.config.sh"
# Run in a fresh set -e shell (bats `run` disables errexit, which is the very thing under test).
run bash -euo pipefail -c '
. "'"$SRC"'/00-prelude.sh"; . "'"$SRC"'/70-build-run.sh"
config_hash() { return 127; } # simulate shasum missing / a broken hash
CORE="'"$core"'"; PROJECT_DIR="'"$pdir"'"; PROJECT_CONFIG="'"$pdir"'/sluice.config.sh"
build
'
assert_failure
rm -rf "$core" "$pdir"
}

@test "build-hash: the confighash is hoisted, not masked in the local array (structural)" {
local body; body="$(sed -n '/^build()/,/^}/p' "$BIN")"
printf '%s\n' "$body" | grep -qF '_chash="$(config_hash)"' # hoisted onto its own assignment
! printf '%s\n' "$body" | grep -qF 'local args=(--label "sluice.confighash=$(config_hash)"' # not inline (masked) in the array
}
46 changes: 46 additions & 0 deletions test/verify-doctor-checks.bats
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,49 @@ assert d['config_error'] is False, d # valid syntax: the parse check passed
run grep -c 'blocked_json=null' "$ROOT/bin/sluice"
[ "$output" -ge 1 ]
}

# Rootless podman can't enforce pids/memory caps or bind ports <1024 (host/kernel limits); doctor
# surfaces the caveats. Drive the real launcher with a fake `podman` on PATH reporting Rootless=true
# (SLUICE_ENGINE forces it over the host's docker).
_fake_rootless_podman_bin() {
mkdir -p "$WORK/bin"
cat > "$WORK/bin/podman" <<'P'
#!/bin/sh
case "$*" in
"info --format {{.Host.Security.Rootless}}") echo true ;;
info) exit 0 ;;
--version) echo "podman version 4.9.3" ;;
*) exit 1 ;;
esac
P
chmod +x "$WORK/bin/podman"
}

@test "doctor: rootless podman surfaces the pids/memory + ports<1024 caveats" {
_fake_rootless_podman_bin
printf 'SLUICE_RUN_CMD="bash"\nSLUICE_MEMORY="512m"\nSLUICE_PORTS="80 8080"\n' > "$WORK/sluice.config.sh"
run bash -c "cd '$WORK' && PATH=\"$WORK/bin:\$PATH\" SLUICE_ENGINE=podman '$SLUICE' doctor"
assert_success
assert_output --partial "cgroups-v2"
assert_output --partial "port 80 (<1024)"
assert_output --partial "may silently not apply"
refute_output --partial "port 8080" # 8080 is >=1024, not flagged
}

@test "doctor: docker engine emits no rootless-podman caveats" {
printf 'SLUICE_RUN_CMD="bash"\nSLUICE_PORTS="80"\n' > "$WORK/sluice.config.sh"
run bash -c "cd '$WORK' && '$SLUICE' doctor"
assert_success
refute_output --partial "cgroups-v2"
}

@test "doctor --json: rootless_podman is true under rootless podman, false under docker" {
_fake_rootless_podman_bin
printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh"
run bash -c "cd '$WORK' && PATH=\"$WORK/bin:\$PATH\" SLUICE_ENGINE=podman '$SLUICE' doctor --json"
assert_success
jq -e '.rootless_podman==true' <<<"$output"
run bash -c "cd '$WORK' && '$SLUICE' doctor --json"
assert_success
jq -e '.rootless_podman==false' <<<"$output"
}
22 changes: 22 additions & 0 deletions test/verify-ls-egress-unit.bats
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ _stub_engine_one_box() {
rm -rf "$XDG_STATE_HOME"
}

@test "ls --json: podman's localhost/ image prefix is stripped so name + state_dir stay canonical" {
_stub_engine_one_box
eng() {
case "$*" in
"image ls --filter label=sluice.confighash --format {{.Repository}}") printf 'localhost/sluice-api\n' ;;
"info") return 0 ;;
*"sluice.project"*) printf '/nonexistent/proj\n' ;;
*"sluice.confighash"*) printf 'abc123def456\n' ;;
*) : ;;
esac
}
run cmd_ls --json
assert_success
jq -e '.[0].name=="sluice-api"' <<<"$output" # not localhost/sluice-api
jq -e '.[0].state_dir|endswith("/sluice/api")' <<<"$output" # not /sluice/localhost/sluice-api
rm -rf "$XDG_STATE_HOME"
}

@test "ls/prune: both image-ls seeds strip podman's localhost/ prefix (structural)" {
[ "$(grep -cF "sed 's,^localhost/,,'" "$BIN")" -ge 2 ]
}

@test "ls --json: a pre-posture image without a confighash label yields confighash:null" {
_stub_engine_one_box
eng() {
Expand Down