Skip to content

feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits - #152

Open
Mikimoto wants to merge 6 commits into
Mcrich23:mainfrom
Mikimoto:feat/hardening-keys
Open

feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits#152
Mikimoto wants to merge 6 commits into
Mcrich23:mainfrom
Mikimoto:feat/hardening-keys

Conversation

@Mikimoto

@Mikimoto Mikimoto commented Sep 1, 2026

Copy link
Copy Markdown

feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits

Summary

Six container-hardening compose keys are currently absent from Service's CodingKeys. Swift's
Codable ignores unknown keys, so today they are dropped with no warning: a compose file that
declares cap_drop: [ALL] and a read-only rootfs with tmpfs mounts runs with the default
capability set and no tmpfs, and nothing in the output says so.

All six have a container run equivalent. This adds them, plus reporting for two things that do
not.

compose key container run
cap_add / cap_drop --cap-add / --cap-drop
tmpfs --mount type=tmpfs,target=…
shm_size --shm-size
init --init
ulimits --ulimit <type>=<soft>[:<hard>]
network_mode (no equivalent — reported)

Why the mapping is a pure function

The mapping lives in ComposeUp.hardeningRunArgs(for:environment:) rather than inline in the
argument builder, and the tests call it directly.

The reason is concrete. The existing tests cover Codable parsing and the already-extracted pure
helpers (clampMemoryLimit, composePortToRunArg, networkRunArg), but runCommandArgs has no
test seam: grep -rn runCommandArgs returns 33 hits in ComposeUp.swift and 0 in Tests/.
That gap is why healthcheck.timeout can be decoded (Healthcheck.swift:48,61,79), asserted on by
two parsing tests, and still never reach waitUntilServiceIsHealthy — no test can see the
difference. Adding six more keys the same way would have reproduced it six more times.

Measured against container 1.0.0, not inferred

Two findings shaped the implementation. Both are reproducible on macOS 27.0 / container 1.0.0.

--tmpfs silently mounts at the literal path when given Compose-style options.

$ container run --rm --tmpfs /run:noexec,nosuid alpine:3 sh -c 'mount | grep tmpfs'
tmpfs on /run:noexec,nosuid type tmpfs (rw,relatime)

There is no error. /run is not mounted, and a directory named /run:noexec,nosuid exists
instead. A read-only container then fails to write /run with a permission error that points
nowhere near the cause. This change therefore uses --mount type=tmpfs exclusively.

--mount type=tmpfs accepts only target, mode and size.

$ container run --rm --mount type=tmpfs,target=/run,noexec alpine:3 true
Error: unknown directive noexec when parsing mount type=tmpfs,target=/run,noexec

$ container run --rm --mount type=tmpfs,target=/run,mode=0755,uid=70,gid=70 alpine:3 true
Error: unknown directive uid when parsing mount type=tmpfs,target=/run,mode=0755,uid=70,gid=70

So noexec, nosuid, nodev, uid and gid cannot be expressed. They are dropped — but
reported, never silently:

Note: Service 'patroni1' tmpfs '/run/postgresql': `container run` accepts only target, mode and
size; dropped noexec,nosuid,uid=70,gid=70.
Warning: Service 'patroni1' tmpfs '/run/postgresql' requested uid/gid ownership, which
`container run` cannot express. The mount will be owned by root, so a non-root container cannot
write to it unless the mode is world-writable.

That second warning is not hypothetical. The mount is root-owned, so:

--user 70:70 --mount type=tmpfs,target=/run/postgresql,mode=0755   →  Permission denied
--user 70:70 --mount type=tmpfs,target=/run/postgresql,mode=0777   →  writes fine

A service running as a non-root user with a read-only rootfs — PostgreSQL putting its socket in
/run/postgresql is the usual case — will fail to start, and the reason is worth one line of
output.

network_mode

container run has no way to express "no network": a container started without --network still
joins the default network and gets an address. network_mode is therefore parsed only so it can be
reported, and produces no run arguments.

Capability ordering is deliberately not asserted

--cap-add and --cap-drop are collected into two separate arrays (Flags.swift:231-241) and the
effective set is computed in RuntimeService.effectiveCapabilitiescap_drop: ALL clears the
base, adds are applied, then individual drops are removed. The order the flags appear in on the
command line carries no meaning, so the tests assert that every declared capability reaches its
flag rather than asserting a sequence.

ulimits

Compose allows both nofile: 65535 and nofile: {soft: 20000, hard: 40000}. container run takes
<type>=<soft>[:<hard>] (Parser.rlimit) and its type names match Compose's exactly, so both forms
map directly. They are normalised through a small UlimitValue decoder.

An entry that cannot be read throws rather than nilling the map. Nilling would drop the sibling
entries with it, which is the same silent-loss shape this change set exists to remove. tmpfs does
the same for a value that is neither a list nor a string.

Tests

Two suites:

  • HardeningArgsTests — per-key parsing and flag mapping, including the ulimits long form,
    whitespace after a comma in tmpfs options, ${VAR} interpolation, and the two throwing paths.
  • HardeningComposeIntegrationTests — the same keys through a whole compose document that shares
    them via a YAML anchor and merge keys. A parser that failed to resolve <<: would pass every
    per-key test and fail here.

Verification on macOS 27.0 / Swift 6.4, branched from main @ 6e6aaf0:

check result
swift build 0 errors
swift test (static suites) 261 tests in 23 suites passed (baseline on main is 236 in 21)
git diff main..HEAD --check no output

Every new behaviour was mutation-checked: reverting it turns the covering test red. The capability
ordering was the one case where a test stayed green under mutation for a good reason, which is what
led to dropping that assertion.

Notes and limits

  • Terminating a container exec on the host does not guarantee the guest process is reaped; not
    introduced here, but relevant to anything built on these flags.
  • size= is passed through as written. container interprets it in MiB, so a byte-valued
    size=1000000 truncates to 0. Compose's own units are not translated; left as-is to avoid
    guessing at intent.
  • security_opt and logging remain unsupported: container inspect's configuration schema has
    no field for the former, and there is no log-driver concept for the latter.
  • stop_grace_period is deliberately untouched — feat: add/support stop_grace_period #150 is already open for it.

…laim

Three problems found by a fresh-context review of this branch, all verified
against the installed apple/container sources rather than inferred:

ulimits: the decoder tried [String: String] then [String: Int] and fell back to
nil. Compose also allows a {soft, hard} pair, which matched neither, so a file
using the long form silently lost its whole ulimits map including any sibling
entries in short form. container run --ulimit takes <type>=<soft>[:<hard>]
(Parser.rlimit) and its type names match Compose's exactly, so the long form is
directly expressible. Both forms now normalise through UlimitValue, and an entry
that cannot be read throws instead of nilling the map.

tmpfs: same silent-nil shape for a value that is neither a list nor a string;
now throws. Options are also trimmed, so "/run:noexec, mode=0755" no longer
drops mode by failing its prefix test.

Capabilities: the comment claimed cap_drop was emitted before cap_add "so that
cap_drop: [ALL] followed by a narrow cap_add behaves as Compose specifies".
That is false. container collects the two flags into separate arrays
(Flags.swift) and computes the effective set in RuntimeService.effectiveCapabilities
- drop-ALL clears the base, adds are applied, individual drops removed - so the
command-line order carries no meaning. Two tests asserted that ordering; they
now assert that every declared capability reaches its flag, which is the real
invariant.

The six new keys also went through the run-args builder without variable
interpolation while every neighbouring key resolved ${VAR}; they now take the
environment and resolve it.
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