Files
alkssh/tasks/implementation/w5-fuzz-harness.md
T
glm-5.3-flash d37cbc0c8a test(fuzz): the four-target fuzz sidecar (w5-fuzz-harness)
The family standing rule (anything worth fuzzing gets fuzzed) on the
crate's ingress surfaces, per the alkgit convention:

- fuzz_targets/{exec_parse,fingerprint,key_decode,terminal_params} —
  thin fuzz_target! wrappers over four ingress surfaces:
  - exec_parse: the production parse path (from_utf8-fallback ingress
    shape), differentially paired against a from-spec grammar oracle;
    typed routing classes + determinism.
  - fingerprint: blob parse + the single fingerprint path; the
    SHA256:/unpadded-base64 contract shape via a from-spec
    base64 round-trip oracle; the committed ssh-keygen -lf vectors
    replayed inside the gate.
  - key_decode: the production decode arm (the #[doc(hidden)]
    decode_key_arm exposure, routed through itself); typed-class +
    bounded-fixed-message invariants; passphrase-shape robustness
    drive on the underlying decode.
  - terminal_params: the pty_request conversion via the #[doc(hidden)]
    terminal_params exposure; exact clamp, verbatim term, the
    reserved-modes {} JSON shape, serialized-shape determinism.
- shared/ — the invariant logic as a stable-clean library; the
  corpus gate (cargo test --manifest-path fuzz/shared/Cargo.toml)
  replays every committed seed through the batteries and joins
  AGENTS.md's verification list.
- corpus/<target>/ committed seeds + the deterministic
  gen_fuzz_seeds.py (no-op-diff verified).
- run-detached.sh + nightly pin; the sidecar excluded from the root
  workspace (workspace + package excludes; publish dry-run clean).

Exposure shape decided + recorded per target (README + lib.rs doc
comments): fingerprint needs none (fully public ingress); the rest
are #[doc(hidden)] re-exports of the production surfaces, never
forks. Detached campaigns ran per target (600 s default budget, fork
mode): all four clean; the two findings of the first round are
recorded in the task file (the harness oracle's double-/-strip bug;
the PPK echo fixed in the preceding commit).

Verification: corpus gate green (11 tests); scripts/matrix.sh green;
taskgraph validate clean.
2026-10-30 11:44:21 +00:00

10 KiB
Raw Permalink Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w5-fuzz-harness Wave 5 — the fuzz sidecar (the crate's ingress surfaces) completed
review-wave-4
moderate medium phase implementation

Description

The family standing rule — anything worth fuzzing gets fuzzed — on the crate's ingress surfaces (docs/plans/implementation.md §Wave 5, the fuzzing bullet): the fuzz/ sidecar per the alkgit convention (fuzz/README.md there — the layout, the detached runner, the corpus gate). Four targets over the plan's ingress inventory:

  • exec_parse — the git-parseable exec grammar + the dispatch parse path (src/git_map.rs's parse + src/dispatch.rs's parse-only routing): no-panic on any command string; the typed decline classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) stable per input; the per-length caps enforced before any allocation (the decline-first-alloc-never invariant under hostile lengths).
  • fingerprint — the SHA256: + unpadded base64 format path (src/fingerprint.rs): no-panic on arbitrary key blobs; the format invariant holds for every accepted input; determinism; the committed ssh-keygen -lf parity vectors still agree.
  • key_decode — the decode_secret_key ingress (src/key_source.rs's decrypt-at-use seam): no-panic on arbitrary plaintext bytes and passphrase shapes; every failure a typed KeySourceError class (decode failure — never a catch-all, never a panic); no secret material retained past the one-time decode (the Zeroizing posture's shape asserted where assertable).
  • terminal_params — the pty_request args → TerminalParamsWire conversion (src/tty_map.rs, the SSH modes → reserved-modes JSON): no-panic on arbitrary term strings, sizes, pixel dims, and mode blobs; oversize/overflow fail-closed (typed, not clamped-silent); the reserved-modes JSON shape stable.

Shape (the alkgit convention, adapted):

  • fuzz_targets/ thin fuzz_target! wrappers; shared/ the invariant logic as a plain stable-clean library so cargo test --manifest-path fuzz/shared/Cargo.toml replays the committed corpora through the same invariants — the corpus gate joins the verification list; the sidecar consumes the crate's public API only (plus #[doc(hidden)] fuzz-visible exposures where the ingress is private — the exposure shape decided + recorded, the alkgit pattern; never a dependency of the root crate).
  • corpus/<target>/ committed hand-made seeds + the deterministic generator (gen_fuzz_seeds.py, no-op-diff check); grown corpora/artifacts/coverage gitignored, never merged back.
  • run-detached.sh — the mandatory detached runner (agent sessions never foreground a campaign); rust-toolchain.toml nightly pin inside fuzz/; the root Cargo.toml gains exclude = ["fuzz"] (the root is a single-member workspace — the sidecar is its own).
  • Campaigns run detached per target (the runner's default budget); any finding fixed in-task or booked for the gate — never absorbed silently.

Acceptance Criteria

  • The fuzz/ sidecar lands with all four targets; the sidecar is excluded from the root workspace and never a root dep
  • The exposure shape decided + recorded (public API vs #[doc(hidden)] per target)
  • Committed seeds + the deterministic generator (no-op diff); the corpus gate (cargo test --manifest-path fuzz/shared/Cargo.toml) green and joining the verification list
  • Detached campaigns run per target; findings fixed or booked (recorded)
  • fuzz/README.md documents the layout + commands (the alkgit shape); the root crate's verification set stays green (the sidecar changes no root behavior)

References

  • docs/plans/implementation.md §Wave 5 (the fuzzing bullet — the four-surface inventory)
  • /workspace/@alkdev/alkgit/fuzz/ (the convention's reference implementation: README, run-detached.sh, gen_fuzz_seeds.py, shared/), docs/plans/implementation.md (alkgit) §Wave 5 (the fuzz-harness task's shape)
  • src/git_map.rs, src/dispatch.rs, src/fingerprint.rs, src/key_source.rs, src/tty_map.rs (the ingress surfaces)
  • docs/architecture/mapping.md §Session-channel dispatch (the decline-first-alloc-never invariant the exec_parse target pins)

Notes

The sidecar as landed: fuzz/ (its own workspace — [workspace] members = [] + nightly pin in fuzz/rust-toolchain.toml; the root Cargo.toml gains exclude = ["fuzz"] at both levels — the [workspace] exclude and the [package] exclude list (the alkgit published-crate policy: the sidecar never ships in a publish; verified with the dry-run)). fuzz/Cargo.lock is committed (the alkgit convention); artifacts/, coverage/, target/, and grown corpora are gitignored.

The exposure-shape decision (per target, recorded) — the sidecar consumes alkssh's public API wherever the ingress is already public, and the production surface itself (never a fork) where it is private:

  • fingerprint — no exposure needed: the ingress is fully public ([alkssh::fingerprint] + the russh 0.65 blob parse parse_public_key_base64, the same re-export surface the crate rides).
  • exec_parse — #[doc(hidden)] re-export of the dispatch parse path: parse_exec, ExecRoute, ExecDecline, GitServiceRoute (made pub in the private dispatch module; the lib re-export gate is the only reachability — the grammar stays a dispatch seam, not a public-API promise).
  • key_decode — #[doc(hidden)] exposure decode_key_arm, the production decrypt_and_decode's decode arm extracted and routed through itself (the fuzz target drives the exact production arm, and the arm takes &Zeroizing<String> so the drive exercises the adapter seam's wrap posture).
  • terminal_params — #[doc(hidden)] re-export (under session) of tty_map::terminal_params — the exact function handler.rs's pty_request row constructs the stored params with.

The harness shape: the deterministic framed feed for terminal_params (term_len u32-LE + term bytes + four u32-LE dims, zero-defaulted, trailing mode blob ignored — the modes are never parsed, OQ-44); the production ingress shape for exec_parse (byte string → from_utf8(..).unwrap_or("") → parse — the handler's exact shape; the pre-parse cap and the git row's repo-id gate are the root contract rows' territory, pinned by tests/dispatch_core.rs); a from-spec base64 round-trip oracle for fingerprint; the exec_parse verdict is differentially paired against a from-spec grammar oracle (fuzz/shared/src/exec_parse.rs), fingerprint parity replays the committed ssh-keygen -lf vectors inside the corpus gate.

Campaigns run (detached via fuzz/run-detached.sh, fork mode, the default 600 s budget each; all four final campaigns exited 0 — zero crashes/OOMs/timeouts):

  • exec_parse — 2 campaigns: the first crashed on a harness-side oracle bug (the oracle stripped two leading / where the grammar strips exactly one — git-upload-pack //.git ); fixed, re-ran clean (40M+ execs).
  • fingerprint — 1 campaign, clean (12M+ execs).
  • key_decode — 2 campaigns: the first found a real crate finding (below); fixed, re-ran clean (3.5M+ execs).
  • terminal_params — 1 campaign, clean (8.8M+ execs).

The real finding (fixed in-task, recorded): russh 0.65's ssh-key PPK parse path (PpkParseError::Header) interpolates the raw header line — the plaintext's own bytes — into its Display, and the decode arm previously passed that message through KeySourceError::Decode's payload: a malformed or PPK-header-shaped vault record would echo decrypted-record material into a stored/logged error string (the ADR-005 decision-7 posture's textual leak). Fix: decode_key_arm (and so every Decode payload the crate ever produces) now carries crate-owned fixed text naming the refusal — the machinery's message never rides the typed payload; error.rs's Decode doc records the semantics. The upstream ask is booked (not absorbed): ssh-key's PPK parse errors should not echo the untrusted header; filed to russh/ssh-key with the owners at the wave-5 gate. Related harness-side finding (also fixed in-task): the no-echo invariant's first shape (a plaintext-contains-message check) was itself unsound — an attacker-chosen plaintext can be a substring of the machinery's fixed text (the campaign's own first crash: plaintext "Could not read key"); the assertable form is the bounded-message pair (the text is a bounded fixed message, ≤ 256 bytes) + the ≥ 256-byte plaintext-run check that cannot false-fire.

Verification list: the corpus gate (cargo test --manifest-path fuzz/shared/Cargo.toml) is green (11 tests — every committed seed replays the invariants on the stable toolchain, plus the fixture parity rows) and has joined AGENTS.md's verification commands. scripts/matrix.sh green after all changes (the sidecar changes no root behavior: 6 builds × the full suite + clippy ×2 -D warnings + fmt + doc, exit 0).

Summary

Completed 2026-10-30. The fuzz/ sidecar landed per the alkgit convention with all four targets over the plan's ingress inventory (exec_parse, fingerprint, key_decode, terminal_params), each a thin fuzz_target! wrapper over the stable-clean fuzz/shared/ invariant library whose corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml) joined the verification list (AGENTS.md); committed hand-made seeds + the deterministic gen_fuzz_seeds.py (no-op-diff verified); the detached runner + nightly pin inside fuzz/; the root workspace excludes the sidecar (workspace + package excludes). The exposure-shape decision is recorded per target (one no-exposure — fingerprint's fully-public ingress; three #[doc(hidden)] production-surface re-exports). Detached campaigns ran per target at the runner's default budget — all clean after findings: one harness-oracle bug (the exec oracle's double-/ strip) and one real crate fix — russh's ssh-key PPP/PPK parse echo (raw header bytes) riding the typed Decode payload; decode_key_arm now carries a crate-owned fixed message and the upstream echo ask is booked, not absorbed. Verification: the sidecar gate green, scripts/matrix.sh green end to end, cargo publish --dry-run clean (the sidecar ships nothing).