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.
This commit is contained in:
glm-5.3-flash committed 2026-10-30 11:44:21 +00:00
1 parent a5fb03e7c6
commit d37cbc0c8a
93 files changed
+4833 -14

No files matched your search

+2
View File
@@ -167,6 +167,8 @@ cargo publish --dry-run --allow-dirty # before a release
scripts/matrix.sh # the decline-matrix runner: the full suite per
# feature build (none / git / session / tunnel /
# sftp / --all-features), clippy ×2, fmt, doc
cargo test --manifest-path fuzz/shared/Cargo.toml # the fuzz corpus gate: the committed seeds replay
# the `fuzz/` sidecar's invariant batteries
```
If feature flags are added, also run `cargo test --all-features` and
+6 -3
View File
@@ -1,8 +1,10 @@
# Single-crate protocol/door crate (the alktty template). No workspace:
# the composition story is deferred — alkssh's substance is the mappings
# (family ADR-003).
# (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded —
# it pins its own nightly toolchain and never rides the root's).
[workspace]
members = ["."]
exclude = ["fuzz"]
[package]
name = "alkssh"
@@ -17,8 +19,9 @@ categories = ["network-programming", "asynchronous"]
# The package policy (the sibling crates' convention): the published crate
# ships the architecture docs (the authoritative contract) but not the
# process surface — reviews, research, plans, task files, the working
# agents' config, AGENTS.md.
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/"]
# agents' config, AGENTS.md — and not the `fuzz/` sidecar (its own
# workspace; never a root dep, never a shipped surface).
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/", "fuzz/"]
[lib]
name = "alkssh"
+5
View File
@@ -0,0 +1,5 @@
artifacts/
coverage/
target/
/corpus/*/*
!/corpus/*/seed-*
+3649
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "alkssh-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alkssh-fuzz-shared = { path = "shared" }
[[bin]]
name = "exec_parse"
path = "fuzz_targets/exec_parse.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fingerprint"
path = "fuzz_targets/fingerprint.rs"
test = false
doc = false
bench = false
[[bin]]
name = "key_decode"
path = "fuzz_targets/key_decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "terminal_params"
path = "fuzz_targets/terminal_params.rs"
test = false
doc = false
bench = false
[workspace]
+71
View File
@@ -0,0 +1,71 @@
# alkssh fuzzing
libFuzzer targets for alkssh's ingress surfaces — the family standing
rule (anything worth fuzzing gets fuzzed), landed per the plan's wave-5
charter and the alkgit convention: the JSON schema gate itself is
alkcall's surface, family-fuzzed there; SSH framing, key exchange, and
the channel multiplexer are russh's wire (family ADR-003) — the
alkssh-owned post-framing ingress is what fuzzes here.
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`#[cfg(test)]` modules; quinn's CI
pattern). The fuzz binaries are nightly-only; the shared crate is
stable-clean. It consumes alkssh's public API only (plus the three
`#[doc(hidden)]` fuzz-visible exposures: the exec-grammar parse path
in `src/dispatch.rs`, the key-decode arm `decode_key_arm` in
`src/key_source.rs`, and the `terminal_params` conversion in
`src/tty_map.rs` — the recorded exposure decisions: the ingresses are
production-private, so the exposures are the production surfaces
themselves, never forks; the fingerprint ingress is fully public API)
and must never become a dependency of the root crate.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root — must be a no-op
diff on the committed set). Only `seed-*` files are tracked; grown
corpora (SHA-named units), artifacts, and coverage output are
gitignored and are **not** merged back into the committed seeds.
- `run-detached.sh` — mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host.
- `gen_fuzz_seeds.py` — deterministic seed generator (quiche pattern).
## Targets
| Target | Drives | Invariants |
|---|---|---|
| `exec_parse` | the production exec-grammar parse (`parse_exec`, `src/dispatch.rs` — the handler's ingress shape: byte string → `from_utf8` fallback `""` → parse; the pre-parse `max_exec_command_len` cap and the git row's `invalid-repo-id` gate are the root contract rows' territory, the parse is everything behind them) | no-panic on any byte string (any length behind the cap); the typed routing classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) agree with a from-spec grammar oracle (the differential pair) and are stable per input; for `Git` routes the repo-id gate classification is deterministic |
| `fingerprint` | the crate's single fingerprint path ([`alkssh::fingerprint`] over `russh::keys::parse_public_key_base64` — ADR-004 decision 1) | no-panic on arbitrary key blobs (a non-blob is the parse's typed refusal); every accepted key's fingerprint is exactly the `SHA256:` + unpadded-standard-base64 contract shape (43-char body, canonical alphabet, decodes to a 32-byte digest, re-encodes canonically); determinism; the committed `ssh-keygen -lf` parity vectors still agree (the corpus-gate test row) |
| `key_decode` | the decrypt-at-use chain's decode arm (`decode_key_arm`, `src/key_source.rs` — the production `decrypt_and_decode` conversion exposed `#[doc(hidden)]`) + the underlying `decode_secret_key` passphrase arm (robustness drive — the production chain always passes `None`) | no-panic on arbitrary plaintext shapes (the vault's `String` shape, lossy from arbitrary bytes) and passphrase shapes; every failure on the production arm is exactly the typed `KeySourceError::Decode` class — never a catch-all, never a panic; the failure text never carries the plaintext; the `Zeroizing` posture rides the drive (the wrap is what the adapter seam applies); determinism |
| `terminal_params` | the `pty_request` args → `TerminalParamsWire` conversion (`terminal_params`, `src/tty_map.rs` — OQ-44's SSH modes → reserved-modes `{}` JSON) | no-panic on arbitrary term strings, u32 dimensions, and mode blobs (the modes are never parsed — reserved); the dimension clamp is exactly the recorded cap (`min(value, 65535)`, never a magnitude truncation); `term` preserved verbatim; the reserved-modes JSON is always exactly `{}`; the full serialized wire shape is deterministic per input |
## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
cargo fuzz build
# agent sessions: detached campaign (never foreground; CWD-independent)
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh <target> [extra libfuzzer args...]
# corpus replay through the invariants (stable toolchain, no nightly)
cargo test --manifest-path fuzz/shared/Cargo.toml
# coverage
cargo fuzz coverage <target>
```
The fuzz workspace is excluded from the main workspace (`exclude` in
the root `Cargo.toml`); it pins its own nightly toolchain via
`rust-toolchain.toml` and does not affect the root workspace's stable
toolchain or MSRV.
Campaign cadence: the corpus gate (above) runs in every verification
pass; the fuzzing sessions themselves run at the wave gate and before
publish (`fuzz/run-detached.sh per target`, bounded runtime).
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/owner/demo'
+1
View File
@@ -0,0 +1 @@
git-receive-pack '/owner/demo'
+1
View File
@@ -0,0 +1 @@
git-upload-pack /r
+1
View File
@@ -0,0 +1 @@
git-receive-pack /owner/demo.git
+1
View File
@@ -0,0 +1 @@
git-upload-pack /owner/demo.git
+1
View File
@@ -0,0 +1 @@
git-send-pack '/r'
+1
View File
@@ -0,0 +1 @@
git-upload-archive '/r'
+1
View File
@@ -0,0 +1 @@
git-send-pack
+1
View File
@@ -0,0 +1 @@
git-upload-pack
+1
View File
@@ -0,0 +1 @@
git-receive-pack
+1
View File
@@ -0,0 +1 @@
git-upload-pack ''
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/.git'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/r.git.git'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/a/b/c'
+1
View File
@@ -0,0 +1 @@
uptime
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
git-upload-packrel
+1
View File
@@ -0,0 +1 @@
git-upload-pack rel
+1
View File
@@ -0,0 +1 @@
GIT-UPLOAD-PACK '/r'
+1
View File
@@ -0,0 +1 @@
git-upload-pack$ '/r'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/r'
+1
View File
@@ -0,0 +1 @@
git-upload-pack'r'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/r
+1
View File
@@ -0,0 +1 @@
git-upload-pack '
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/é'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/日本語'
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
git-upload-pack ' '
+1
View File
@@ -0,0 +1 @@
git-upload-pack '''
+1
View File
@@ -0,0 +1 @@
git-upload-pack '//r'
+1
View File
@@ -0,0 +1 @@
git-upload-pack /r.git.git'
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+1
View File
@@ -0,0 +1 @@
git-upload-pack /bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.git
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
git-upload-pack '/r'
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z alkssh-w1-fixture-alpha
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ alkssh-w1-fixture-bravo
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe alkssh-w1-fixture-charlie
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd
+1
View File
@@ -0,0 +1 @@
not a key
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAIM28
+1
View File
@@ -0,0 +1 @@
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14ztrailing-garbage
+1
View File
@@ -0,0 +1 @@
ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
+1
View File
@@ -0,0 +1 @@
爠睡戠瑹獥
+2
View File
@@ -0,0 +1,2 @@
AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
+1
View File
@@ -0,0 +1 @@
BAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMwAAAKDJQe/myUHv
5gAAAAtzc2gtZWQyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMw
AAAEATzyiI7Cwpy3CJBDKDepykbHiNzVpZ38kiDqa1S1p5Us28sP5UAl3npJJ8trZgPt2k
E+Af6477/Rx6J2VbM14zAAAAF2Fsa3NzaC13MS1maXh0dXJlLWFscGhhAQIDBAUG
-----END OPENSSH PRIVATE KEY-----
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0AAAAKAUKLAWFCiw
FgAAAAtzc2gtZWQyNTUxOQAAACDL7vUvoR/Q7oO33Yhy3lvgfZHnvouPeYzwHQR/GGjr0A
AAAEC9aPqnyC37OKCL/VksdonIl6ICy+I8X4So6TDmnSObEMvu9S+hH9Dug7fdiHLeW+B9
kee+i495jPAdBH8YaOvQAAAAF2Fsa3NzaC13MS1maXh0dXJlLWJyYXZvAQIDBAUG
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
this is not a key
View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDNvLD+VAJd56SSfLa2YD7dpBPgH+uO+/0ceidlWzNeMwAAAKDJQe/myUHv
5gAAAAtzc2gtZWQyNTUxOQAAACDNvLD
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN OPENSSH PRIVATE KEY-----
garbage
-----END OPENSSH PRIVATE KEY-----
+2
View File
@@ -0,0 +1,2 @@
-----BEGIN OPENSSH PRIVATE KEY-----
AAAA
+1
View File
@@ -0,0 +1 @@
unicodeé日本語
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
PEM-adjacent-but-not
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
#![no_main]
//! The exec-grammar target (`mapping.md` §Session-channel dispatch —
//! the git-parseable exec grammar + the dispatch parse path) — thin
//! wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
alkssh_fuzz_shared::exec_parse::fuzz_exec_parse(data);
});
+11
View File
@@ -0,0 +1,11 @@
#![no_main]
//! The fingerprint-format target (ADR-004 decision 1 — the `SHA256:` +
//! unpadded base64 path) — thin wrapper over the shared invariant
//! battery.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
alkssh_fuzz_shared::fingerprint::fuzz_fingerprint(data);
});
+11
View File
@@ -0,0 +1,11 @@
#![no_main]
//! The key-decode target (ADR-005 decision 2 — the decrypt-at-use
//! chain's `decode_secret_key` seam) — thin wrapper over the shared
//! invariant battery.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
alkssh_fuzz_shared::key_decode::fuzz_key_decode(data);
});
+11
View File
@@ -0,0 +1,11 @@
#![no_main]
//! The terminal-parameters target (`tty_map.rs` — the `pty_request`
//! args → `TerminalParamsWire` conversion, the SSH modes → reserved
//! modes JSON) — thin wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
alkssh_fuzz_shared::terminal_params::fuzz_terminal_params(data);
});
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Regenerate the committed seed corpora for alkssh's fuzz targets.
Writes into fuzz/corpus/<target>/. Deterministic: fixed inputs only, no
randomness (the fixture-backed reads are of the committed files).
Run from the repo root:
python3 fuzz/gen_fuzz_seeds.py
Target seed functions and the main() registry land here with the
target tasks; the shared seed_name()/write_seed() plumbing is stable
(the alkblobs family pattern). Only `seed-*` files are tracked; grown
corpora (SHA-named units), artifacts, and coverage output are
gitignored and are never merged back into the committed seeds.
"""
import os
ALPHA_PUB_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z"
)
BRAVO_PUB_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ"
)
CHARLIE_PUB_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe"
)
def seed_name(target, i):
return os.path.join("fuzz", "corpus", target, f"seed-{i:03d}")
def write_seed(target, i, data):
path = seed_name(target, i)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def read_bytes(path):
with open(path, "rb") as f:
return f.read()
# ---------------------------------------------------------------- exec_parse
def exec_parse_seeds():
return [
# the vanilla wire shapes (the grammar's serving services)
b"git-upload-pack '/owner/demo'",
b"git-receive-pack '/owner/demo'",
b"git-upload-pack /r",
b"git-receive-pack /owner/demo.git",
# unquoted absolute with the .git idiom stripped
b"git-upload-pack /owner/demo.git",
# the decline-with-reason class (known non-serving services)
b"git-send-pack '/r'",
b"git-upload-archive '/r'",
b"git-send-pack",
# service-only (SSH exec must carry the repo)
b"git-upload-pack",
b"git-receive-pack ",
# the normalization's edge shapes
b"git-upload-pack ''",
b"git-upload-pack '/'",
b"git-upload-pack '/.git'",
b"git-upload-pack '/r.git.git'",
b"git-upload-pack '/a/b/c'",
# the pipe row (the probe's pinned non-git verdicts)
b"uptime",
b"",
b"git-upload-packrel",
b"git-upload-pack rel",
b"GIT-UPLOAD-PACK '/r'",
b"git-upload-pack$ '/r'",
b"git-upload-pack '/r'",
b"git-upload-pack'r'",
b"git-upload-pack '/r",
b"git-upload-pack '",
# hostile shapes: unicode, control bytes, interior nulls, quotes
"git-upload-pack '/\u00e9'".encode(),
"git-upload-pack '/\u65e5\u672c\u8a9e'".encode(),
b"git-upload-pack '/\x00\x01'",
b"git-upload-pack ' '",
b"git-upload-pack '''",
b"git-upload-pack '//r'",
b"git-upload-pack /r.git.git'",
# hostile lengths: the grammar rides any length behind the cap
b"git-upload-pack '/" + b"a" * 253 + b"'",
b"git-upload-pack /" + b"b" * 99 + b".git",
b"\x00git-upload-pack '/r'",
b"git-upload-pack\t'/r'",
]
# --------------------------------------------------------------- fingerprint
def fingerprint_seeds():
valid = (
"AAAAC3NzaC1lZDI1NTE5AAAAICS7Ub4A073+G450Nt2DUOwWyniGVU/lrlUoQZAUg1sd"
)
return [
# the committed fixture blobs (the parity-vector keys)
(ALPHA_PUB_BLOB + " alkssh-w1-fixture-alpha").encode(),
(BRAVO_PUB_BLOB + " alkssh-w1-fixture-bravo").encode(),
(CHARLIE_PUB_BLOB + " alkssh-w1-fixture-charlie").encode(),
# the ssh-keygen README example blob
valid.encode(),
# hostile shapes: non-blobs and near-blobs
b"not a key",
b"",
b"AAAAC3NzaC1lZDI1NTE5AAAAIM28", # truncated valid prefix
ALPHA_PUB_BLOB.encode() + b"trailing-garbage",
b"ssh-rsa " + ALPHA_PUB_BLOB.encode(), # wrong type prefix
b"\xff\xfe raw bytes",
# whitespace-padded valid blob (the trim normalization)
(" \n" + ALPHA_PUB_BLOB + "\n").encode(),
# a valid blob with one flipped base64 char
(("B" if ALPHA_PUB_BLOB.startswith("A") else "A").encode() + ALPHA_PUB_BLOB.encode()[1:]),
]
# ---------------------------------------------------------------- key_decode
def key_decode_seeds():
alpha = read_bytes(os.path.join("tests", "vectors", "alpha.key"))
bravo = read_bytes(os.path.join("tests", "vectors", "bravo.key"))
return [
# the committed fixture PEMs decode (the production arm's success row)
alpha,
bravo,
# hostile plaintext shapes: every Decode-class refusal
b"this is not a key",
b"",
alpha[: len(alpha) // 2], # truncated PEM
alpha + b"\x00trailing", # valid PEM with trailing garbage
b"-----BEGIN OPENSSH PRIVATE KEY-----\ngarbage\n-----END OPENSSH PRIVATE KEY-----\n",
b"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n",
"unicode\u00e9\u65e5\u672c\u8a9e".encode(),
b"\x00\x01\x02\x03\x04",
b"PEM-adjacent-but-not", # near-shape garbage
]
# ------------------------------------------------------------ terminal_params
def framed(term, cols, rows, pix_w=0, pix_h=0, mode_blob=b""):
data = len(term).to_bytes(4, "little") + term
data += cols.to_bytes(4, "little")
data += rows.to_bytes(4, "little")
data += pix_w.to_bytes(4, "little")
data += pix_h.to_bytes(4, "little")
return data + mode_blob
def terminal_params_seeds():
return [
# the vanilla shape (an 80x24 xterm, no pixels, no modes)
framed(b"xterm-256color", 80, 24, 0, 0),
# the clamp's edge: exactly u16::MAX, then a hostile dim beyond it
framed(b"w", 65535, 65536, 2**32 - 1, 70000),
# overflow shapes: u32 maxima everywhere
framed(b"vt100", 2**32 - 1, 2**32 - 1, 2**32 - 1, 2**32 - 1),
# hostile term strings: empty, unicode, control bytes
framed("\u00e9\u65e5\u672c".encode(), 1, 1, 0, 1),
framed(b"\x00\x01\x07\x1b", 5, 5, 5, 5),
# zero-defaulted dims (the feed's totality over short inputs)
b"\x00",
b"\x02\x00\x00\x00vt",
# arbitrary mode blobs after the framed fields (never parsed)
framed(b"xterm", 80, 24, 128, 48, b"\x53\x01\x00\x00\x00\x40"),
framed(b"xterm", 80, 24, 0, 0, b"\xff" * 32),
# truncated feeds
framed(b"xterm-256color", 80, 24),
]
TARGETS = [
("exec_parse", exec_parse_seeds),
("fingerprint", fingerprint_seeds),
("key_decode", key_decode_seeds),
("terminal_params", terminal_params_seeds),
]
def main():
for target, generate in TARGETS:
for i, data in enumerate(generate()):
write_seed(target, i, data)
print("committed corpora regenerated (deterministic; expect no-op diff)")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Detached fuzzing runner for agent sessions: the fuzz campaign never
# runs as a foreground child of the session (OOM in a target must not
# take down the agent host), and survives the session ending.
#
# Usage: fuzz/run-detached.sh <target> [extra libfuzzer args...]
# (works from the repo root or from fuzz/; CWD-independent)
# FUZZ_RUNTIME_SECS overrides the per-campaign budget (default 600 s).
#
# Poll instead of waiting:
# tail -n 50 fuzz/artifacts/<target>-*.log
# ls fuzz/artifacts/<target>/ (crash-* / oom-* / timeout-* files)
# pgrep -f "cargo fuzz run <target>"
set -euo pipefail
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
shift
root="$(git rev-parse --show-toplevel)"
fuzz_dir="$root/fuzz"
mkdir -p "$fuzz_dir/artifacts"
runtime="${FUZZ_RUNTIME_SECS:-600}"
log="$fuzz_dir/artifacts/${target}-$(date -u +%Y%m%d-%H%M%S).log"
cd "$fuzz_dir"
setsid nohup cargo fuzz run "$target" -- \
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
-max_total_time="$runtime" "$@" \
>"$log" 2>&1 < /dev/null &
echo "pid=$! log=$log"
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["llvm-tools-preview"]
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "alkssh-fuzz-shared"
version = "0.0.0"
publish = false
edition = "2021"
[dependencies]
alkssh = { path = "../..", features = ["session"] }
alkgit = "0.1"
russh = { version = "0.65", default-features = false, features = ["aws-lc-rs"] }
serde_json = "1"
zeroize = "1.9"
+54
View File
@@ -0,0 +1,54 @@
//! The corpus-replay plumbing: the committed seeds replay through the
//! invariant batteries on the stable toolchain (`cargo test
//! --manifest-path fuzz/shared/Cargo.toml` runs this in
//! `#[cfg(test)]` modules across the ingress units — the quinn CI
//! pattern), so the gate never needs a fuzzing session.
pub fn read_corpus(target: &str) -> Vec<Vec<u8>> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../corpus")
.join(target);
let mut seeds = Vec::new();
let entries = std::fs::read_dir(&dir).unwrap_or_else(|error| {
panic!("the committed corpus for {target} is present ({dir:?}): {error}")
});
for entry in entries {
let path = entry.expect("corpus entry readable").path();
let data = std::fs::read(&path).expect("corpus entry readable");
seeds.push(data);
}
seeds.sort();
seeds
}
#[macro_export]
macro_rules! corpus_replay {
($target:literal, $battery:path, $min:literal) => {
#[cfg(test)]
mod corpus_replay {
use super::*;
#[test]
fn committed_corpus_replays_through_the_invariants() {
let seeds = $crate::corpus::read_corpus($target);
assert!(
seeds.len() >= $min,
"committed seed corpus is present, found {}",
seeds.len()
);
for data in &seeds {
$battery(data);
}
}
#[test]
fn empty_and_unit_inputs_replay() {
$battery(b"");
$battery(b"hello");
$battery(b"\x00\x00\x00\x00");
$battery(b"\xff\xfe raw bytes");
$battery(b"a");
}
}
};
}
+105
View File
@@ -0,0 +1,105 @@
//! The exec-grammar ingress (`mapping.md` §Session-channel dispatch,
//! `dispatch.rs`): the production `parse_exec` path driven on arbitrary
//! command strings — no-panic, typed routing classes, and agreement
//! with a from-spec re-derivation of the grammar. The production
//! ingress shape is the handler's: the per-length cap
//! (`Limits::max_exec_command_len`) is enforced *before* the parse
//! (decline first, alloc never — the cap's pre-parse placement is
//! pinned by the root's contract rows in `tests/dispatch_core.rs`);
//! everything behind the cap is this battery: a byte string →
//! `std::str::from_utf8(..).unwrap_or("")` → the parse (invalid UTF-8
//! is the pipe row, the wave-2 rule).
use alkssh::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
/// The battery: no-panic on any byte string; the verdict agrees with
/// the from-spec oracle ([`spec_parse`]); the verdict (and the git
/// row's repo-id gate classification for `Git` routes) is stable per
/// input; every `Git` route carries one of the two serving services
/// and a normalized argument.
pub fn fuzz_exec_parse(data: &[u8]) {
let command = std::str::from_utf8(data).unwrap_or("");
let route = parse_exec(command);
assert_eq!(
route,
spec_parse(command),
"the parse disagrees with the from-spec oracle: {command:?} → {route:?}"
);
assert_eq!(
parse_exec(command),
route,
"the parse verdict is not deterministic: {command:?}"
);
if let ExecRoute::Git { repo, .. } = &route {
assert_eq!(
alkgit::validate_repo_id(repo),
alkgit::validate_repo_id(repo),
"the git row's repo-id gate is not deterministic: {command:?}"
);
}
}
/// The from-spec oracle: the grammar re-derived from the dispatch
/// module's documented rules, sharing no code with the
/// implementation — the differential pair (the repo_id target's
/// oracle pattern).
fn spec_parse(command: &str) -> ExecRoute {
let (token, argument) = match command.split_once(' ') {
Some((token, argument)) => (token, Some(argument)),
None => (command, None),
};
match token {
"git-upload-pack" => spec_serving(GitServiceRoute::UploadPack, argument),
"git-receive-pack" => spec_serving(GitServiceRoute::ReceivePack, argument),
"git-send-pack" => ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-send-pack",
}),
"git-upload-archive" => ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-upload-archive",
}),
_ => ExecRoute::Pipe,
}
}
/// The serving-service arm of the oracle: a serving token must carry
/// a non-empty, path-shaped argument (SSH exec must carry the repo —
/// service-only is the decline-with-reason class; anything else
/// unparseable is the pipe row).
fn spec_serving(service: GitServiceRoute, argument: Option<&str>) -> ExecRoute {
match argument {
None => ExecRoute::Declined(ExecDecline::ServiceOnly),
Some(argument) if argument.is_empty() => ExecRoute::Declined(ExecDecline::ServiceOnly),
Some(argument) => match spec_normalize(argument) {
Some(repo) => ExecRoute::Git { service, repo },
None => ExecRoute::Pipe,
},
}
}
/// The argument grammar re-derived: a path-shaped argument — fully
/// single-quote-wrapped (a non-empty inner ending in a quote) or
/// unquoted absolute — normalized by stripping the quotes, one
/// leading `/`, and one trailing `.git` suffix (an empty-after-strip
/// prefix keeps the suffix; the git row's validation declines the
/// empties, never a silent emptying).
fn spec_normalize(argument: &str) -> Option<String> {
let stripped = if let Some(inner) = argument.strip_prefix('\'') {
if inner.is_empty() {
return None;
}
inner.strip_suffix('\'')?
} else if argument.starts_with('/') {
argument
} else {
return None;
};
let without_slash = stripped.strip_prefix('/').unwrap_or(stripped);
Some(match without_slash.strip_suffix(".git") {
Some(prefix) if !prefix.is_empty() => prefix.to_owned(),
_ => without_slash.to_owned(),
})
}
crate::corpus_replay!("exec_parse", fuzz_exec_parse, 12);
+152
View File
@@ -0,0 +1,152 @@
//! The fingerprint format ingress (ADR-004 decision 1,
//! `fingerprint.rs`): arbitrary key blobs through the crate's single
//! fingerprint path — the composition of russh's public OpenSSH blob
//! parse (`russh::keys::parse_public_key_base64`, the same re-export
//! the crate's surface rides) and the crate's public
//! [`alkssh::fingerprint`]. The ingress is fully public API — the
//! recorded exposure decision needs no `#[doc(hidden)]` surface.
//!
//! Invariants: no-panic on any byte string (a non-blob is the parse's
//! typed refusal, never a panic and never a partial success); every
//! accepted key's fingerprint is exactly the `SHA256:` + unpadded
//! standard base64 contract shape (one `:` separator, 43-char body,
//! canonical alphabet, 32-byte digest decoded exactly); determinism
//! (same key, same string — and the same string re-parses to the same
//! verdict). The committed `ssh-keygen -lf` parity vectors
//! (`tests/vectors/`) are replayed by the corpus-gate tests below.
use alkssh::fingerprint;
use russh::keys::parse_public_key_base64;
/// The battery.
pub fn fuzz_fingerprint(data: &[u8]) {
let text = String::from_utf8_lossy(data);
let trimmed = text.trim();
let first = parse_public_key_base64(trimmed);
let second = parse_public_key_base64(trimmed);
let verdict = match (first, second) {
(Ok(key), Ok(other)) => {
assert_eq!(
fingerprint(&key),
fingerprint(&other),
"the blob parse verdict is not deterministic: {trimmed:?}"
);
key
}
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
panic!("the blob parse verdict is not deterministic: {trimmed:?}")
}
(Err(_), Err(_)) => return,
};
let key = verdict;
let fp = fingerprint(&key);
assert_eq!(
fingerprint(&key),
fp,
"the fingerprint is not deterministic: {fp}"
);
let Some((prefix, body)) = fp.split_once(':') else {
panic!("the fingerprint carries no separator: {fp}");
};
assert_eq!(prefix, "SHA256", "the fingerprint prefix: {fp}");
assert_eq!(
body.len(),
43,
"the unpadded base64 of a 32-byte digest is 43 chars: {fp}"
);
let decoded = decode_unpadded(body)
.unwrap_or_else(|| panic!("the fingerprint body is not standard base64: {fp}"));
assert_eq!(
decoded.len(),
32,
"the SHA-256 digest body decodes to 32 bytes: {fp}"
);
assert_eq!(
encode_unpadded(&decoded),
body,
"the fingerprint body is not the canonical unpadded encoding: {fp}"
);
}
/// A from-spec unpadded standard-base64 decoder (RFC 4648, no
/// padding): the round-trip half of the format invariant, sharing no
/// code with the implementors.
fn decode_unpadded(body: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut acc: u32 = 0;
let mut bits = 0usize;
for character in body.chars() {
let value = match character {
'A'..='Z' => (character as u32) - ('A' as u32),
'a'..='z' => (character as u32) - ('a' as u32) + 26,
'0'..='9' => (character as u32) - ('0' as u32) + 52,
'+' => 62,
'/' => 63,
_ => return None,
};
acc = (acc << 6) | value;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xff) as u8);
}
}
if acc & ((1 << bits) - 1) != 0 {
return None;
}
Some(out)
}
/// A from-spec unpadded standard-base64 encoder — the re-encode half.
fn encode_unpadded(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut acc: u32 = 0;
let mut bits = 0usize;
for byte in bytes {
acc = (acc << 8) | u32::from(*byte);
bits += 8;
while bits >= 6 {
bits -= 6;
out.push(ALPHABET[((acc >> bits) & 0x3f) as usize]);
}
}
if bits > 0 {
out.push(ALPHABET[((acc << (6 - bits)) & 0x3f) as usize]);
}
String::from_utf8(out).unwrap_or_default()
}
crate::corpus_replay!("fingerprint", fuzz_fingerprint, 8);
#[cfg(test)]
mod parity {
use super::*;
/// The committed `ssh-keygen -lf` vectors still agree: every
/// fixture's computed fingerprint is byte-verbatim the recorded
/// independent-issuer value.
#[test]
fn the_committed_parity_vectors_still_agree() {
let manifest = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/fingerprints.txt"))
.expect("the parity manifest is readable");
for name in ["alpha", "bravo", "charlie", "delta", "ca"] {
let line = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../tests/vectors/{name}.pub")))
.unwrap_or_else(|error| panic!("the {name}.pub fixture is readable: {error}"));
let blob = line
.split_whitespace()
.nth(1)
.unwrap_or_else(|| panic!("{name}.pub carries a base64 blob"));
let key = parse_public_key_base64(blob)
.unwrap_or_else(|error| panic!("{name}.pub parses: {error}"));
let expected = format!("{name}.pub {}", fingerprint(&key));
assert!(
manifest.contains(&expected),
"{name}'s computed fingerprint is not the recorded ssh-keygen value"
);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The key-decode ingress (ADR-005 decision 2, `key_source.rs`): the
//! decrypt-at-use chain's one-time `decode_secret_key` conversion —
//! driven through the production arm itself
//! ([`alkssh::decode_key_arm`], the `#[doc(hidden)]` exposure that is
//! `decrypt_and_decode`'s exact decode stage, not a fork).
//!
//! Invariants: no-panic on arbitrary plaintext shapes (the production
//! plaintext is the vault's `String` — valid UTF-8 by return type; the
//! battery shapes arbitrary bytes into that form via the lossy
//! conversion and drives it through the same `Zeroizing` wrap the
//! adapter seam applies) and arbitrary passphrase shapes (the
//! underlying decode's passphrase arm — the production chain always
//! passes `None`; the `Some` arm is robustness drive only); every
//! failure is the typed [`KeySourceError::Decode`] class — never a
//! catch-all, never another variant on this arm, never a panic; the
//! failure text never carries the plaintext (the assertable half of
//! the Zeroizing posture — the plaintext exists solely inside the
//! call, dropped zeroized; no retention, no static cache — and the
//! typed error's only payload is the decode machinery's message).
//!
//! The no-echo invariant's assertable form is *the bounded-message
//! pair* (reshaped in-task after the first campaign's finding): the
//! failure text is a bounded fixed message (≤ 256 bytes — the decode
//! machinery's vocabulary, never a growable echo), and no 256-byte
//! contiguous plaintext run appears in it — which, given the bound,
//! cannot false-fire (a string shorter than 256 bytes cannot contain a
//! 256-byte run). A plaintext-`contains`-message check in the other
//! direction was unsound: an attacker-chosen plaintext can be a
//! substring of the fixed text (the campaign's own first crash:
//! plaintext `"Could not read key"`).
use alkssh::{KeySourceError, decode_key_arm};
use zeroize::Zeroizing;
/// The battery.
pub fn fuzz_key_decode(data: &[u8]) {
let plaintext = Zeroizing::new(String::from_utf8_lossy(data).into_owned());
let first = decode_key_arm(&plaintext);
let second = decode_key_arm(&plaintext);
typed_decode_class(&first, &plaintext);
match (&first, &second) {
(Ok(key), Ok(other)) => {
assert_eq!(
fingerprint(&key),
fingerprint(other),
"the decode arm is not deterministic"
);
}
(Err(a), Err(b)) => assert_eq!(
a.to_string(),
b.to_string(),
"the decode arm's failure is not deterministic: {a} vs {b}"
),
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
panic!("the decode arm is not deterministic: {first:?} vs {second:?}")
}
}
let mid = data.len() / 2;
let pass = String::from_utf8_lossy(&data[mid..]);
let _ = russh::keys::decode_secret_key(&plaintext, Some(&pass));
let _ = russh::keys::decode_secret_key(&plaintext, Some(&String::from_utf8_lossy(data)));
}
fn fingerprint(key: &russh::keys::PrivateKey) -> String {
key.public_key().fingerprint(russh::keys::HashAlg::Sha256).to_string()
}
/// The typed-class invariant: `Ok` or exactly
/// [`KeySourceError::Decode`] — the decode arm's only failure shape —
/// and the Decode text is the bounded fixed-message pair (see the
/// module docs; the two assertions are jointly airtight).
fn typed_decode_class(outcome: &Result<russh::keys::PrivateKey, KeySourceError>, plaintext: &str) {
match outcome {
Ok(_) => {}
Err(KeySourceError::Decode(message)) => {
assert!(
message.len() <= 256,
"the decode failure text is not a bounded fixed message: {message:?}"
);
let target = message.as_bytes();
if plaintext.len() >= 256 && target.len() >= 256 {
let bytes = plaintext.as_bytes();
let tail_start = bytes.len() - 256;
for start in [0, (bytes.len() / 4).min(tail_start), tail_start] {
let run = &bytes[start..start + 256];
assert!(
target.windows(256).all(|window| window != run),
"the typed decode failure carried plaintext material: {message:?}"
);
}
}
}
Err(other) => panic!("the decode arm produced a non-decode class: {other:?}"),
}
}
crate::corpus_replay!("key_decode", fuzz_key_decode, 8);
#[cfg(test)]
mod invariants {
use super::*;
/// The production arm against a real fixture: a committed OpenSSH
/// PEM plaintext decodes to the same key the direct decode gives
/// (the ADR-005 one-time conversion, exercised through the
/// exposure).
#[test]
fn the_production_arm_decodes_a_fixture_pem() {
let pem = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/alpha.key"),
)
.expect("the alpha fixture is readable");
let loaded = decode_key_arm(&Zeroizing::new(pem.clone()))
.unwrap_or_else(|error| panic!("the fixture PEM decodes: {error}"));
let direct = russh::keys::decode_secret_key(&pem, None)
.unwrap_or_else(|error| panic!("the direct decode works: {error}"));
assert_eq!(fingerprint(&loaded), fingerprint(&direct));
}
/// A plaintext that is not OpenSSH-PEM key material is a
/// [`KeySourceError::Decode`] refusal (the inner-format contract —
/// decision 1, never a fallback).
#[test]
fn non_key_plaintext_is_the_typed_decode_refusal() {
let outcome = decode_key_arm(&Zeroizing::new("this is not a key".to_owned()));
assert!(matches!(outcome, Err(KeySourceError::Decode(_))));
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Invariant logic shared by alkssh's fuzz targets. Kept out of the
//! fuzz-target binaries so the corpus replay unit tests can exercise the
//! same invariant checks against every committed corpus entry (the quinn
//! CI pattern) without a nightly toolchain.
pub mod corpus;
pub mod exec_parse;
pub mod fingerprint;
pub mod key_decode;
pub mod terminal_params;
+115
View File
@@ -0,0 +1,115 @@
//! The terminal-parameters ingress (`tty_map.rs`, OQ-44's reserved
//! modes): the `pty_request` args → `TerminalParamsWire` conversion
//! driven through the production [terminal_params] surface (the
//! `#[doc(hidden)]` exposure — `handler.rs`'s `pty_request` row
//! constructs the stored params with exactly this function).
//!
//! The harness's framed feed (deterministic, seed-friendly):
//! `term_len` (u32 LE, bounded to the remaining input) + the term
//! bytes + four u32 LE dimensions (`cols`/`rows`/pixel width/height,
//! zero-defaulted when the input runs out) + an arbitrary trailing
//! mode blob. The mode blob is *never parsed* — the SSH ttymodes ride
//! the reserved `{}` JSON (OQ-44, backends ignore modes in v1) — so
//! the battery's totality over arbitrary mode blobs is by
//! construction; the framing demonstrates it (the conversion's
//! outcome never varies with the blob).
//!
//! Invariants: no-panic on any term string (empty, unicode, control
//! characters) and any u32 dimensions; the dimension clamp is the
//! recorded cap (`min(value, 65535)` — a dimension beyond 65535 is
//! nonsense on both papers) computed exactly, never a magnitude
//! truncation; `term` is preserved verbatim; the reserved-modes JSON
//! is always exactly the empty object; the full wire shape is
//! deterministic per input (the serialized form compared).
use alkssh::terminal_params;
/// The battery.
pub fn fuzz_terminal_params(data: &[u8]) {
let mut cursor = data;
let term_len = take_u32(&mut cursor) as usize;
let term_len = term_len.min(cursor.len());
let (term_bytes, rest) = cursor.split_at(term_len);
cursor = rest;
let term = String::from_utf8_lossy(term_bytes);
let cols = take_u32(&mut cursor);
let rows = take_u32(&mut cursor);
let pixel_width = take_u32(&mut cursor);
let pixel_height = take_u32(&mut cursor);
let _mode_blob = cursor;
let params = terminal_params(&term, cols, rows, pixel_width, pixel_height);
assert_eq!(
&params.term,
&Some(term.to_string()),
"the conversion did not preserve `term` verbatim: {term:?}"
);
assert_eq!(
params.cols,
spec_clamp(cols),
"the cols clamp is not the recorded cap: {cols}"
);
assert_eq!(
params.rows,
spec_clamp(rows),
"the rows clamp is not the recorded cap: {rows}"
);
assert_eq!(
params.pixel_width,
spec_clamp(pixel_width),
"the pixel-width clamp is not the recorded cap: {pixel_width}"
);
assert_eq!(
params.pixel_height,
spec_clamp(pixel_height),
"the pixel-height clamp is not the recorded cap: {pixel_height}"
);
assert!(
params.modes.is_object()
&& params
.modes
.as_object()
.is_some_and(|map| map.is_empty()),
"the reserved modes JSON is not the empty object: {:?}",
params.modes
);
match serde_json::to_string(&params.modes) {
Ok(text) => assert!(text == "{}", "the reserved modes JSON shape: {text}"),
Err(error) => panic!("the reserved modes JSON never fails to serialize: {error}"),
}
let shape = serde_json::to_string(&params);
let replayed = terminal_params(&term, cols, rows, pixel_width, pixel_height);
match (shape, serde_json::to_string(&replayed)) {
(Ok(first), Ok(second)) => assert_eq!(
first, second,
"the conversion's wire shape is not deterministic: {term:?} {cols}×{rows}"
),
_ => panic!("the params JSON shape never fails to serialize"),
}
}
/// The cursor's u32 (zero-defaulted when the framed field is absent —
/// the feed is total over any input).
fn take_u32(cursor: &mut &[u8]) -> u32 {
if cursor.len() >= 4 {
let (bytes, rest) = cursor.split_at(4);
*cursor = rest;
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
} else {
*cursor = &[];
0
}
}
/// The recorded clamp, from the spec text (`tty_map.rs`: "u32 wire
/// dims → the wire's u16 … the cap, not a truncation of useful data").
fn spec_clamp(value: u32) -> u16 {
if value > (u16::MAX as u32) {
u16::MAX
} else {
value as u16
}
}
crate::corpus_replay!("terminal_params", fuzz_terminal_params, 8);
+7 -7
View File
@@ -79,11 +79,11 @@ use russh::ChannelOpenFailure;
/// skeleton probe's `ExecRoute::Git`/`Pipe` split, extended by the
/// `w3-git-mapping` grammar with the declined-with-reason class).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ExecRoute {
pub enum ExecRoute {
/// A git-parseable command — routes to the git row. The repo path
/// is the normalized argument grammar (quotes, leading `/`, one
/// trailing `.git`); validation (`alkgit::validate_repo_id`) is
/// the git row's gate ([`crate::git_map`]).
/// the git row's gate (the `crate::git_map` module).
Git {
service: GitServiceRoute,
repo: String,
@@ -99,10 +99,10 @@ pub(crate) enum ExecRoute {
}
/// The git service the exec grammar recognized — the ADR-016 wire
/// strings, as the door-side form of [`alkgit::Service`] (the seam maps
/// strings, as the door-side form of `alkgit::Service` (the seam maps
/// them verbatim; this type keeps the parse core feature-free).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GitServiceRoute {
pub enum GitServiceRoute {
/// `git-upload-pack` — fetch.
UploadPack,
/// `git-receive-pack` — push.
@@ -114,7 +114,7 @@ pub(crate) enum GitServiceRoute {
/// the validation gate; that one needs `alkgit`'s grammar, this type
/// is the parse core's).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExecDecline {
pub enum ExecDecline {
/// A known non-serving git service arrived on the wire:
/// `git-send-pack` / `git-upload-archive` (POC-3 §2 — the honest
/// surface; ADR-016's refusal).
@@ -136,10 +136,10 @@ impl ExecDecline {
}
}
/// The exec-grammar parse — the wave-2 [`classify_exec`] probe's
/// The exec-grammar parse — the wave-2 `classify_exec` probe's
/// replacement, its unit rows migrated (see the grammar section for
/// the exact shape rules).
pub(crate) fn parse_exec(command: &str) -> ExecRoute {
pub fn parse_exec(command: &str) -> ExecRoute {
let (token, argument) = match command.split_once(' ') {
Some((token, argument)) => (token, Some(argument)),
None => (command, None),
+19
View File
@@ -93,6 +93,13 @@ pub use config::SessionSeam;
#[cfg(feature = "tunnel")]
pub use config::TunnelListenSeam;
pub use config::{DialConfig, Limits, ServeConfig};
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the exec
/// grammar's parse path (`dispatch.rs`) — the `fuzz/` sidecar's
/// `exec_parse` target drives the production parse, never a fork. Not
/// documented API: the grammar is the dispatch seam's, not a public-API
/// promise.
#[doc(hidden)]
pub use dispatch::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
pub use error::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason};
pub use fingerprint::fingerprint;
/// Contract-test surface (`#[doc(hidden)]`): the per-connection auth
@@ -103,6 +110,11 @@ pub use fingerprint::fingerprint;
#[doc(hidden)]
pub use handler::SessionHandler;
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the
/// decrypt-at-use chain's decode arm (`key_source.rs`, the production
/// `decrypt_and_decode` conversion). Not documented API.
#[doc(hidden)]
pub use key_source::decode_key_arm;
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
/// Contract-test surface (`#[doc(hidden)]`): the spawn-and-return
/// plumbing (`ADR-006 decision 4`, see `serve.rs`) — the mechanics the
@@ -110,3 +122,10 @@ pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySourc
/// real wire to pin the constraint as tests. Not documented API.
#[doc(hidden)]
pub use serve::{report_exit_status_then_close, spawn_serving_path};
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`, under
/// `session`): the `pty_request` args → `TerminalParamsWire`
/// conversion (`tty_map.rs`, OQ-44's reserved modes). Not documented
/// API.
#[cfg(feature = "session")]
#[doc(hidden)]
pub use tty_map::terminal_params;
+1 -1
View File
@@ -215,7 +215,7 @@ pub(crate) fn clamp_u16(value: u32) -> u16 {
/// negotiation section): SSH ttymodes → the **reserved modes JSON
/// `{}`** (alktty OQ-44's reservation — the map is honest: nothing
/// is carried, exactly what the reservation asks).
pub(crate) fn terminal_params(
pub fn terminal_params(
term: &str,
cols: u32,
rows: u32,
+106 -3
View File
@@ -1,7 +1,7 @@
---
id: w5-fuzz-harness
name: Wave 5 — the fuzz sidecar (the crate's ingress surfaces)
status: pending
status: completed
depends_on: [review-wave-4]
scope: moderate
risk: medium
@@ -92,8 +92,111 @@ Shape (the alkgit convention, adapted):
## Notes
> To be filled by implementation agent
**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
> To be filled on completion
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).