Skip to content

fix(runtime): seven self-contained audit findings - #181

Merged
passcod merged 7 commits into
mainfrom
fix/audit-trivial-runtime
Sep 11, 2026
Merged

passcod merged 7 commits into
mainfrom
fix/audit-trivial-runtime

Conversation

@passcod

@passcod passcod commented Sep 11, 2026

Copy link
Copy Markdown
Member

🤖 Seven trivial findings from the logic-bug audit in the runtime and defs layers, one commit each.

  • ExternalService rows could not be read back. resource_instances.kind was written with format!("{:?}") and parsed by a hand-written match missing that arm, so find_instance on a row the reconciler writes returned a conversion failure. Writing now goes through an exhaustive resource_kind_str, so a new variant stops compiling instead of silently becoming unreadable, and a round-trip test covers every variant — including that the spelling still matches Debug, since the column already holds rows written that way.

  • A zero deadline meant "fail immediately" instead of the default. l[rt.started.state-methods] says zero or absent uses the state's default and l[rt.stop] defers to it, but all five sites went through d.max(0) as u64, so ready(0) failed about two seconds in with "Barrier deadline of 0s exceeded". Negatives now throw rather than clamping to zero.

  • volume.write refused .. paths that stay inside the root. The rule is "must not escape after canonicalisation"; the code rejected the component outright, so /etc/../etc/conf was refused. The spec contradicted itself here — l[rt.write] paraphrased the rule as "must not contain .. components" while claiming to restate l[volume.write.validation], which is where the stricter reading came from — so the paraphrase now matches the rule it cites.

  • Wildcard ACME issuance could never complete. challenge_record_name("*.example.com") produced _acme-challenge.*.example.com; RFC 8555 places a wildcard's challenge at the base domain, and no zone holds a name with a * label in the middle. Nothing rejects a wildcard on the way in and build_csr deliberately preserves wildcard SANs, so this was reachable by design.

  • Re-warming an image left a pending pin expiry in place. r[image.pin.expiry] clears an expiration once the reference is observed valid again, but upsert_pin used ON CONFLICT DO NOTHING, so a re-warmed image was still swept later. DO UPDATE names only expires_at — pinned_at belongs to the insert, and naming no more than this writer owns is what the unscoped-overwrite rule asks for.

  • NAT64 was blamed for a missing IPv6 egress. An AAAA-only endpoint on a host without v6 egress reported the only reason there was, so the operator was told NAT64 was inactive even when it was on — and it could not have helped, since it translates v6 to v4. NoIpv6Egress carries that case and the reason travels with the host into the fault set.

  • A non-string schema param was treated as absent. apply_schema filtered on v.as_str(), so a submitted JSON number was silently replaced by the field's default, or reported as a missing required field. Both name the wrong problem: a value that is present but unusable is not an absence.

Three existing tests asserted behaviour these changes correct, and all three keep their original intent:

  • barrier_deadline_zero_expires_on_second_pass was testing the bug itself, and now asserts the default is taken.
  • rt_stop_deadline_is_enforced used a zero deadline as a way to force expiry; it uses the smallest real deadline and lets it elapse.
  • dns_aaaa_only_without_v6_egress_is_unroutable asserted the wrong reason; the endpoint is still unroutable and the test now names the right cause.

Two findings from this subsystem are not here, both on cards rather than forced into the sweep. /forwards/start port validation needs a pod traversal that does not exist yet (V4). Instance GC skipping the active desired state needs keep-set membership the GC cannot see — it is computed at reconcile time and not persisted (X4).

`resource_instances.kind` was written with `format!("{:?}")` and read by a
hand-written match missing the `ExternalService` arm. The reconciler
persists those rows, so `find_instance` on one returned a
`FromSqlConversionFailure` instead of the instance — latent today because
only tests call it, but a persisted row the reader cannot parse is a trap
waiting for the first production caller.

Writing now goes through an exhaustive `resource_kind_str`, so a new
variant stops compiling rather than silently becoming unreadable, and a
round-trip test covers every variant — including that the spelling still
matches Debug, since the column already holds rows written that way.
…failure

`l[rt.started.state-methods]` says the deadline argument must be a positive
integer and that zero or absent uses the state's default; `l[rt.stop]`
defers to the same rule. All five call sites went through
`d.max(0) as u64`, so zero became `Some(0)` — which `elapsed >= d` trips
on the very next pass, the exact opposite of the documented fallback. A
script writing `ready(0)` for the default 30s got "Barrier deadline of 0s
exceeded" about two seconds later.

Negatives now throw rather than clamping to zero: the spec calls for a
positive integer, and the defs layer's rule is to refuse malformed input
rather than coerce it into meaning something else.

Two existing tests used a zero deadline as a way to force immediate
expiry, so both asserted the behaviour this changes.
`barrier_deadline_zero_expires_on_second_pass` was testing the bug itself
and now asserts the default is taken; `rt_stop_deadline_is_enforced` still
tests what it meant to, using the smallest real deadline and letting it
elapse.
`l[volume.write.validation]` forbids a path that escapes the volume root
*after canonicalisation*. The implementation rejected any path containing
a `..` component at all, so `/etc/../etc/conf` — which resolves safely
inside the volume — was refused.

Resolution is textual, which is right for this check: it runs at
definition time, where the volume does not exist yet and a symlink inside
it could not be followed anyway.

The spec contradicted itself. `l[rt.write]` paraphrased the rule as "must
not contain `..` components" while claiming to restate
`l[volume.write.validation]`, which is where the stricter reading came
from; the paraphrase now matches the rule it cites.

`rt_write_rejects_path_traversal` asserted the old message mentioned
`'..'`. The path it uses still escapes and is still refused, so the test
keeps its meaning and checks the new wording.
`challenge_record_name("*.example.com")` produced
`_acme-challenge.*.example.com`. RFC 8555 places a wildcard's DNS-01
challenge at the base domain — `_acme-challenge.example.com` — and no zone
will hold a name with a `*` label in the middle, so the provider call
failed and wildcard issuance could never complete.

Nothing rejects a wildcard hostname on the way in, and `build_csr`
deliberately preserves wildcard SANs, so this path is reachable by design
rather than by misuse. Only a leading `*.` is stripped; a `*` anywhere
else is not a wildcard.
`r[image.pin.expiry]` says expirations "are cleared whenever a pin's
reference is observed to be valid again for the owning app". An explicit
`rt.warm_images` is exactly that observation, but `upsert_pin` used
`ON CONFLICT DO NOTHING`, so an `expires_at` stamped earlier by the
post-update reconciliation rule stayed put — and the re-warmed image was
swept on a later tick as though the app had stopped referring to it.

`DO UPDATE` names only `expires_at`. `pinned_at` records when the pin was
first taken and belongs to the insert; naming no more than this writer owns
is what the repo's rule about unscoped row overwrites asks for.
An endpoint with only AAAA records on a host without IPv6 egress reported
`NeedsNat64ButDisabled`, the only `UnroutableReason` there was. The
reconciler then told the operator the endpoint "require[s] NAT64 but NAT64
is not active" — even when NAT64 was active, and it could not have helped
either way: NAT64 translates v6 to v4, and what is missing here is v6
egress.

`NoIpv6Egress` carries that case, the reason travels with the host into
the fault set, and the two get sentences naming the remedy each actually
needs.

`dns_aaaa_only_without_v6_egress_is_unroutable` asserted the old reason.
The endpoint is still unroutable, which is what the test was for, so it
keeps its meaning and now names the right cause.
… as absent

`apply_schema` built its submitted map with `v.as_str()` inside a
`filter_map`, so a schema-declared param supplied as a JSON number or bool
was skipped as though it had not been sent. If the field had a default, the
caller's value was silently replaced by it; if it was required with no
default, the caller was told a required field was missing — when it was
present and merely the wrong type.

Both answers name the wrong problem, which is the point: a value that is
present but unusable is not an absence. It is an error now, and the
rejected call leaves `params` untouched.
@github-code-quality

github-code-quality Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: TypeScript, Rust

TypeScript / code-coverage/vitest

The overall line coverage in commit fecc7c0 in the fix/audit-trivial-ru... branch remains at 66%, unchanged from commit c4eab9d in the main branch.

Rust / code-coverage/rust

The overall line coverage in commit fecc7c0 in the fix/audit-trivial-ru... branch remains at 61%, unchanged from commit c4eab9d in the main branch.

Show a line coverage summary of the most impacted files.
File main c4eab9d fix/audit-trivial-ru... fecc7c0 +/-
crates/core/src...ntime/images.rs 83% 83% 0%
crates/core/src...rier/runtime.rs 70% 70% 0%
crates/core/src...ncile/faults.rs 5% 5% 0%
crates/core/src...time/history.rs 82% 83% +1%
crates/core/src/defs/volume.rs 98% 99% +1%
crates/core/src...ction_params.rs 71% 77% +6%
crates/core/src...time/tls/dns.rs 48% 68% +20%

Updated September 11, 2026 00:23 UTC

@passcod
passcod added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 604f832 Sep 11, 2026
17 checks passed
@passcod
passcod deleted the fix/audit-trivial-runtime branch September 11, 2026 02:43
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