Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3855bb1c99 | ||
|
|
fb7cfe3851 | ||
|
|
e72e1fc4c7 | ||
|
|
49f70d9efb | ||
|
|
e95459c425 | ||
|
|
d14941ffb1 | ||
|
|
01e94914c1 | ||
|
|
32809af840 | ||
|
|
67cf253621 | ||
|
|
bc4a9b6024 | ||
|
|
af9dbaedea |
No files matched your search
@@ -199,8 +199,9 @@ implementation agents.
|
||||
registry runs the ACL before the wrapper) and runs the ownership
|
||||
check in the establisher (ADR-010 — it is part of the
|
||||
establishment phase since alkcall 0.5.0 ADR-049, so a denial is
|
||||
`channel:open_failed`, not an in-band frame; the pump handler
|
||||
only allocates and pumps). Terminal sessions are resources per
|
||||
`channel:open_failed`, not an in-band frame; since alkcall 0.6.0
|
||||
ADR-010 §2A the establisher also allocates — the pump handler
|
||||
only pumps). Terminal sessions are resources per
|
||||
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
||||
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
||||
dropped the old `action` arg).
|
||||
@@ -237,6 +238,7 @@ cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo doc --no-deps
|
||||
cargo test --all-features
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay — the standing fuzz gate
|
||||
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
|
||||
cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||||
cargo publish --dry-run --allow-dirty # before a release
|
||||
@@ -247,6 +249,13 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||||
`cargo test --all-features` exercises the `local` backend (PTY mode is
|
||||
`#[cfg(unix)]`; pipe mode is cross-platform).
|
||||
|
||||
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
|
||||
seed through the same invariant functions the cargo-fuzz targets run —
|
||||
on stable, no nightly needed. Run detached campaigns
|
||||
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a
|
||||
release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||||
`src/control.rs`, the adapter, or the session pump.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
|
||||
@@ -311,9 +320,9 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||||
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
|
||||
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||||
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
|
||||
breaking changes are expected at this major-zero stage; this is the
|
||||
first real consumer, so we find and fix issues upstream rather than
|
||||
working around them. Pin `alkcall = "0.5.0"` (the
|
||||
channel-open establishment phase — ADR-049, review 006 E-01/N-1;
|
||||
adopted by alktty ADR-010) and bump deliberately.
|
||||
`StreamError`) come from `alkcall::core`. alkcall is v0.8.x —
|
||||
breaking changes are expected at this major-zero stage; this is the
|
||||
first real consumer, so we find and fix issues upstream rather than
|
||||
working around them. Pin `alkcall = "0.8.1"` (0.8.1 fixes the
|
||||
duplicate adopt/open channel-state destruction found by alkcall's
|
||||
fuzz campaign) and bump deliberately.
|
||||
+212
@@ -4,6 +4,212 @@ All notable changes to this crate are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.6.0] - 2026-09-28
|
||||
|
||||
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
|
||||
parse surfaces, with zero findings in the crate — every crash the
|
||||
campaigns produced was a harness-model gap, and every fix landed in the
|
||||
harness, not the crate. The `alkcall` requirement also updates to
|
||||
0.8.1. No API, wire-format, or behavior change; this release ships no
|
||||
source change in `src/` at all — the deltas are the fuzz workspace,
|
||||
the dependency bump, and this changelog. Minor bump per the 0.x
|
||||
ecosystem-coordination rule (alktty's public signatures reference
|
||||
alkcall types, so downstream crates must unify on alkcall 0.8.1).
|
||||
|
||||
### Added
|
||||
|
||||
- **cargo-fuzz workspace under `fuzz/`** (per
|
||||
`docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook):
|
||||
four targets over the wire-facing surfaces, with the invariant logic
|
||||
in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus
|
||||
replays on stable — the nightly pin is scoped to the subtree via
|
||||
`fuzz/rust-toolchain.toml`:
|
||||
- `chunk_frame` — the 5-byte chunk codec
|
||||
(`ChunkReader`/`ChunkWriter`), including the
|
||||
peek/disambiguation seam (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` in both call orders must yield exactly one
|
||||
chunk).
|
||||
- `negotiation_frame` — the 4-byte BE length-prefixed negotiation
|
||||
framing + `NegotiateRequest` JSON parse + `error_response_bytes`,
|
||||
and the cross-codec invariant that an error-response frame's
|
||||
length-prefix high byte is `0x00` so a `ChunkReader` peek
|
||||
distinguishes it from a chunk (ADR-001 §5).
|
||||
- `control_json` — `ControlMessage` JSON parse + `signal_from_name`.
|
||||
- `session_opseq` — the stateful target: a `#[derive(Arbitrary)]`
|
||||
12-op sequence drives the real producer session pump
|
||||
(`drive_session_pre_negotiated`) over a duplex pair against a
|
||||
fuzz-local mock backend, asserting the wire-contract invariants
|
||||
(kill-on-`Drop` per ADR-005, exit-chunk-is-last per ADR-004,
|
||||
lossless per-stream FIFO + single stdout sentinel, stdin
|
||||
prefix-losslessness, control dispatch bounds, take-once
|
||||
allocation, teardown termination).
|
||||
- **Committed corpus: 340 hand-made seeds** across the four targets
|
||||
(deterministic generator `fuzz/gen_fuzz_seeds.py`; the
|
||||
`session_opseq` seeds are hand-encoded against the `arbitrary` 1.4.x
|
||||
derive layout with the encoding pinned by a decode test). Grown
|
||||
corpus entries are gitignored and excised after campaigns.
|
||||
- **The standing fuzz gate**: `cargo test --manifest-path
|
||||
fuzz/shared/Cargo.toml` replays every committed seed through the
|
||||
same invariant functions the targets run, on stable. Added to the
|
||||
verification checklist.
|
||||
- **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh`
|
||||
(setsid/nohup, fork mode, bounded RSS) — never in the foreground of
|
||||
an agent session.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.**
|
||||
alkcall 0.8.1 is a patch release fixing the duplicate
|
||||
adopt/open channel-state destruction found by alkcall's own fuzz
|
||||
campaign; it changed no type alktty touches. Downstream crates must
|
||||
unify on alkcall 0.8.x per the caret requirement. Minor bump per
|
||||
the 0.x ecosystem-coordination rule.
|
||||
- **Root `Cargo.toml` gains a `[workspace]` table**
|
||||
(`members = ["."]`, `exclude = ["fuzz"]`) so the fuzz subtree's
|
||||
nightly dev-deps never join the root workspace (an MSRV footgun),
|
||||
and `fuzz/` joins the publish exclude. Verified: the packaged file
|
||||
list is unchanged except for the exclusion (43 files; the fuzz
|
||||
workspace, seeds, plans, reviews, and `.opencode/` all stay out of
|
||||
the crate).
|
||||
|
||||
### Verification
|
||||
|
||||
- 0 findings across all four targets: detached campaigns (10 min each
|
||||
on the three wire targets; 10 min on `session_opseq` at 42.3k execs,
|
||||
cov 3934 → 4075 and still growing at budget end) plus a 60s smoke
|
||||
run on target 4 — no crash/OOM/timeout/leak.
|
||||
- Corpus replay 9 tests green on stable (340 seeds); crate tests 113
|
||||
default / 156 all-features; clippy stable + wasm; fmt; doc; wasm
|
||||
check — all pass.
|
||||
- The three crashes the harness-model smoke runs surfaced were
|
||||
harness gaps, each fixed in the fuzz harness (the alkcall §7.7
|
||||
pattern): a failed client write can mean the session completed and
|
||||
dropped the server duplex half mid-write — the harness drains and
|
||||
requires the exit chunk on any write/shutdown error (a premature
|
||||
close is a finding). No adapter code changed.
|
||||
|
||||
## [0.5.0] - 2026-09-18
|
||||
|
||||
The alkcall 0.8.0 adoption: a dependency bump only — no source change
|
||||
in alktty. alkcall 0.8.0 landed review 008's remediation (the
|
||||
establisher reply projection, flavor-form open-op ids in discovery, the
|
||||
in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a
|
||||
post-landing hardening sweep; all new surfaces are additive on the
|
||||
call-plane JSON and the registry seams, and the channels data plane is
|
||||
untouched. The wire format, the direct-ALPN path, the `TtyBackend`
|
||||
trait, and alktty's public API surface are unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.0.**
|
||||
alkcall 0.8.0 changed no type that alktty touches: the open-op
|
||||
machinery alktty registers through (`ChannelCore`/
|
||||
`register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler`
|
||||
/`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`,
|
||||
`ChannelOpenError::CallFailed`) is shape-identical to 0.7.1 — the
|
||||
new `open_channel_with_reply` / reply-field / relay / hub-leg
|
||||
surfaces are additive and alktty exercises none of them. alktty's
|
||||
own open op is the standard-shape name `channels/tty/sub`, so the
|
||||
new flavor-form `channel_open_alpn` discovery path stays on the
|
||||
byte-stable standard derivation, and with no establisher
|
||||
reply-fields configured the open reply is byte-identical to
|
||||
pre-0.8.0. alktty's public signatures reference alkcall types, so
|
||||
downstream crates must now unify on alkcall 0.8.0. Minor bump per
|
||||
the 0.x ecosystem-coordination rule.
|
||||
- **`alkcall` requirement updated to `0.8.0`; lockfile bumped from
|
||||
0.7.1 to 0.8.0.** Verification counts are unchanged from the 0.7.1
|
||||
baseline (113 default / 156 all-features) — the adoption required no
|
||||
source change in alktty.
|
||||
|
||||
## [0.4.1] - 2026-09-10
|
||||
|
||||
The MSRV floor becomes honest: `rust-version` raises from 1.85 to
|
||||
1.88. The 1.85 claim was already false at the dependency level — the
|
||||
lockfile pins `alkcall 0.7.x`, and alkcall 0.7.1 raised its MSRV floor
|
||||
to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path;
|
||||
iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0
|
||||
regardless of what Cargo.toml declares. The raise breaks no downstream
|
||||
that could build the crate before. No API or wire-format change;
|
||||
downstreams on caret requirements pick this up on their next `cargo
|
||||
update`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain
|
||||
(full test suite, clippy). alktty's own code is 1.85-clean —
|
||||
`cargo check` under a 1.85 toolchain passes with the pre-bump
|
||||
lockfile — so the raise is dependency-driven only and needed no
|
||||
clippy fixes. Behavior-identical.
|
||||
- **`alkcall` requirement updated to `0.7.1`; lockfile bumped from
|
||||
0.7.0 to 0.7.1.** Version-requirement-only (0.7.x is already the
|
||||
resolved family); alkcall 0.7.1 changed no type alktty touches.
|
||||
|
||||
## [0.4.0] - 2026-09-07
|
||||
|
||||
The alkcall 0.7.0 adoption (the connect-side caller-identity seam,
|
||||
CF-005/006/007): a dependency bump plus one behavior-alignment change
|
||||
on the channels path. The wire format, the direct-ALPN path, the
|
||||
`TtyBackend` trait, and the public API surface are unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.7.0.**
|
||||
alkcall 0.7.0 changed no type that alktty touches
|
||||
(`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are
|
||||
identical to 0.6.0; alktty never constructs `ServingConfig`), but
|
||||
alktty's public signatures reference alkcall types, so downstream
|
||||
crates must now unify on alkcall 0.7.0. Minor bump per the 0.x
|
||||
ecosystem-coordination rule.
|
||||
- **The channels establisher and pump handler take their identity
|
||||
from the per-call `auth` (CF-006 alignment).** `register_openable`
|
||||
no longer captures the install-time identity and overrides the
|
||||
establisher/handler `auth.identity` with it — post-alkcall-0.7 the
|
||||
per-call context already carries the dispatch-resolved opener (the
|
||||
same identity view the registry's ACL gate checked; the end client,
|
||||
not the hub, on hub-forwarded opens). Behavior-identical in the
|
||||
per-connection-registry deployment (install-time identity was the
|
||||
caller's); the ownership check and the ACL gate now see the same
|
||||
subject in every deployment. Two factory parameters dropped
|
||||
(`make_tty_establisher`/`make_tty_open_handler` are private; no
|
||||
public-surface change).
|
||||
|
||||
## [0.3.0] - 2026-09-07
|
||||
|
||||
The alkcall 0.6.0 adoption (review 007 R-01 — the `Establishment`
|
||||
plan payload): backend allocation moves into the channels establisher.
|
||||
Breaking on the channels path's allocation-failure shape; the wire
|
||||
format, the direct-ALPN path, and the `TtyBackend` trait are
|
||||
unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING — `alkcall` bumped to 0.6.0; channels-path allocation
|
||||
failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the
|
||||
reserved `Establishment` field (`plan: Option<ChannelPlan>`,
|
||||
review 007 R-01), so `backend.allocate` moved from the pump handler
|
||||
into the establisher — the allocated `TtyHandle` crosses to the
|
||||
handler via the plan payload (a private per-open one-shot slot; the
|
||||
handle is not `Sync`). Allocation failure now fails the open op
|
||||
itself: `TtySessionError::ChannelsOpen(CallFailed{
|
||||
channel:open_failed, reason: "dial_failed" })` instead of the
|
||||
post-open in-band `NegotiationRejected{ "allocate_failed" }`. The
|
||||
SSH contract now covers every failure class: no channel ever exists
|
||||
opener-side, no `channel_id`, no `0x00`-prefixed in-band frame. The
|
||||
direct-ALPN path's in-band vocabulary (including `allocate_failed`)
|
||||
is unchanged.
|
||||
- `tty_open_spec()`'s `channel:open_failed` `ErrorDefinition` declares
|
||||
`dial_failed` in the `details.reason` enum (four reachable reasons:
|
||||
`dial_failed` / `unknown_resource` / `handler_error` / `timeout`).
|
||||
- Handler closures in `make_tty_open_handler` gain the
|
||||
`Option<ChannelPlan>` parameter (alkcall 0.6 `OpenHandler` shape);
|
||||
a `None` plan (no-establisher registration) falls back to the
|
||||
pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping
|
||||
the in-band error-frame vocabulary for that registration shape.
|
||||
- New tests: establisher unit gate for allocate-in-establisher (plan
|
||||
slot carries the live handle; failure maps to `dial_failed`), the
|
||||
end-to-end `dial_failed` open-failure surface (flipped from the
|
||||
pinned in-band test), and the handler's `plan: None` fallback arm.
|
||||
|
||||
## [0.2.0] - 2026-09-06
|
||||
|
||||
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
|
||||
@@ -199,4 +405,10 @@ alknet mono-repo.
|
||||
the client→backend pump is aborted at session end instead of
|
||||
lingering until the client disconnects.
|
||||
|
||||
[0.6.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.6.0
|
||||
[0.5.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.5.0
|
||||
[0.4.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.1
|
||||
[0.4.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.0
|
||||
[0.3.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.3.0
|
||||
[0.2.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.2.0
|
||||
[0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.5.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f"
|
||||
checksum = "bf6af1e4af1460480849adf959c972d65deeafc36f4101064ced1ce6d1adfd5b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktty"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"async-trait",
|
||||
|
||||
+8
-4
@@ -1,15 +1,19 @@
|
||||
[package]
|
||||
name = "alktty"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels."
|
||||
repository = "https://git.alk.dev/alkdev/alktty"
|
||||
readme = "README.md"
|
||||
keywords = ["tty", "terminal", "pty", "channels", "alkcall"]
|
||||
categories = ["network-programming", "asynchronous"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md", "fuzz/"]
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[lib]
|
||||
name = "alktty"
|
||||
@@ -25,7 +29,7 @@ default = []
|
||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.5.0"
|
||||
alkcall = "0.8.1"
|
||||
# Minimal, wasm-clean tokio features. `local` adds `process` +
|
||||
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
|
||||
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
|
||||
|
||||
@@ -37,7 +37,7 @@ tty-specific and therefore not ported into alktty's ADR range).
|
||||
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
|
||||
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
|
||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) |
|
||||
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment — in-band frames shrink to `allocate_failed`) |
|
||||
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload — allocation moves into the establisher, no in-band frame from a registered producer) |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ cannot carry the allocated handle across — see ADR-010 §2). The R4
|
||||
tests that asserted the in-band frames for those classes now assert
|
||||
the call-error shape.
|
||||
|
||||
Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0 — review 007 R-01):
|
||||
`Establishment` gained its plan payload, so `allocate` also moved
|
||||
into the establisher — the in-band error-frame path on this channel
|
||||
shrinks to nothing from a registered producer (the frames remain the
|
||||
defense-in-depth fallback for no-establisher registrations and the
|
||||
direct path). See ADR-010 §2A.
|
||||
|
||||
## Context
|
||||
|
||||
Before this ADR, the channels path carried the negotiation twice. The
|
||||
|
||||
@@ -10,6 +10,19 @@ error frame is retained — two transports, two contracts").
|
||||
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
|
||||
`channel:open_failed`, typed `ChannelOpenError`).
|
||||
|
||||
**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2 — review
|
||||
007 R-01):** `Establishment` gained its plan payload
|
||||
(`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's
|
||||
payloadless-`Establishment` blocker. Backend `allocate` moves into
|
||||
the establisher; the allocated `TtyHandle` crosses to the pump
|
||||
handler via the plan; `allocate_failed` joins the call-error surface
|
||||
as `details.reason == "dial_failed"` (the target refused or the
|
||||
backend lacked capacity). No failure class arrives in-band on the
|
||||
channels path from a registered producer; the in-band error-frame
|
||||
fallback stays for no-establisher registrations (defense-in-depth)
|
||||
and the direct-ALPN path. See §2A below; §2 is retained as
|
||||
historical record.
|
||||
|
||||
Amends ADR-009's R4 amendment: the in-band error-frame path on the
|
||||
channels path shrinks to one failure class (`allocate_failed`).
|
||||
|
||||
@@ -68,18 +81,26 @@ the vocabulary is alkcall's one-way wire surface):
|
||||
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
|
||||
| unknown backend | `UnknownResource` | `unknown_resource` |
|
||||
| ownership denial | `HandlerError` | `handler_error` |
|
||||
| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` |
|
||||
| establishment deadline | (wrapper) | `timeout` |
|
||||
|
||||
`dial_failed` and `resource_shortage` have no TTY producer path (TTY
|
||||
dials nothing at open time; `allocate` capacity failures are the
|
||||
in-band class below) — the spec's `ErrorDefinition` for
|
||||
`channel:open_failed` declares only the three reachable reasons.
|
||||
`HandlerError` covers both the malformed-negotiation semantics (the
|
||||
request shape is wrong) and the ACL-outcome ownership denial (an
|
||||
authorization failure, not a dial failure).
|
||||
`resource_shortage` has no TTY producer path (allocation-capacity
|
||||
failures map to `dial_failed` — the target refused or the backend
|
||||
lacked capacity; §2A). `HandlerError` covers both the
|
||||
malformed-negotiation semantics (the request shape is wrong) and the
|
||||
ACL-outcome ownership denial (an authorization failure, not a dial
|
||||
failure).
|
||||
|
||||
### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band
|
||||
|
||||
> **Amended away (2026-09-07) — see §2A.** This section is the
|
||||
> historical record of the pre-0.3.0 shape; its blocker 1
|
||||
> (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049
|
||||
> amendment 2, review 007 R-01). Blocker 2 (double-`allocate`
|
||||
> unsoundness) is avoided by construction in the amended shape: the
|
||||
> establisher's handle *is* the handle the handler pumps — there is no
|
||||
> second allocate to be unsound about.
|
||||
|
||||
The one failure class that cannot migrate: `backend.allocate`. Two
|
||||
reasons, both structural:
|
||||
|
||||
@@ -114,6 +135,60 @@ no-establisher registration still compiles against alkcall's API and
|
||||
must not silently EOF), and the R4 seam test pins the handler-side
|
||||
frame.
|
||||
|
||||
### 2A. Amendment (alkcall 0.6.0): allocation moves into the establisher
|
||||
|
||||
alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the
|
||||
reserved field: `Establishment { plan: Option<ChannelPlan> }` with
|
||||
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, wrapper
|
||||
threaded to the `OpenHandler`'s new second parameter. §2's blocker 1
|
||||
is gone; the double-`allocate` hazard (blocker 2) is structurally
|
||||
absent — one allocate per open, whose result the handler consumes.
|
||||
|
||||
The amended shape:
|
||||
|
||||
1. **The establisher allocates.** After the validation sequence (§1),
|
||||
it runs `backend.allocate(¶ms)`. Failure maps to
|
||||
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`
|
||||
— the target refused or the backend lacked capacity; one string
|
||||
message, no nuance — the same granularity the direct path's
|
||||
`allocate_failed` frame always had). The
|
||||
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
|
||||
therefore declares four reachable reasons (`dial_failed`,
|
||||
`unknown_resource`, `handler_error`, `timeout`).
|
||||
2. **The handle crosses via the plan.** `TtyHandle` is not `Sync`
|
||||
(boxed dyn streams), so it cannot be the `ChannelPlan` directly.
|
||||
The establisher wraps it in a private `AllocatedHandle` one-shot
|
||||
slot (`Mutex<Option<TtyHandle>>`), created **per establisher
|
||||
invocation** — per open. The wrapper moves this open's plan to this
|
||||
open's handler (alkcall's concurrent-same-resource test pins that
|
||||
plans are never shared), the handler `take()`s it exactly once, and
|
||||
establisher-before-handler ordering is alkcall's wrapper guarantee
|
||||
(ADR-049 §1). The shared-slot race §2 described is unreachable by
|
||||
construction: there is no shared slot.
|
||||
3. **The handler pumps the pre-allocated handle.** With a plan it
|
||||
calls `drive_session_pre_allocated` (new `pub(crate)` driver: the
|
||||
pumps directly, no validation pass). With `plan: None` — a
|
||||
no-establisher registration — it falls back to
|
||||
`drive_session_pre_negotiated`, whose inline validate-and-allocate
|
||||
keeps the full in-band error-frame vocabulary. That fallback is
|
||||
defense-in-depth for the assembly-layer freedom to register without
|
||||
an establisher, not a registered-producer path.
|
||||
4. **Consumer surface.** `TtySession::open_via_channels` sees
|
||||
allocation failure as
|
||||
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
|
||||
dial_failed })` — a typed call error, branchable and retryable
|
||||
like every other establishment class. The `0x00` peek on the
|
||||
channel stream becomes a formality that no registered producer
|
||||
ever exercises; the pinned test flipped with the contract
|
||||
(`allocate_failure_fails_open_as_dial_failed` in `testing.rs`,
|
||||
plus the establisher unit gate `establisher_allocates_and_maps_
|
||||
failure_to_dial_failed` in `channels.rs`).
|
||||
|
||||
The R-02 lifetime contract (the handler's `JoinHandle` tracks the
|
||||
data-plane lifetime) was already alktty's shape — the open handler
|
||||
awaits the driver inline in its spawned task — so the amendment adds
|
||||
no lifetime changes.
|
||||
|
||||
### 3. The direct-ALPN path is unchanged
|
||||
|
||||
`TtyAdapter::handle` / `drive_session` keep the wire-frame
|
||||
@@ -149,10 +224,10 @@ unchanged (`true` direct, `false` channels — the registry's
|
||||
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
|
||||
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||
| **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
|
||||
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
|
||||
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
|
||||
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
|
||||
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
|
||||
|
||||
`TtySessionError::ChannelsOpen` now carries alkcall's typed
|
||||
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened
|
||||
@@ -167,10 +242,12 @@ on `establishment_reason()` without unwrapping strings.
|
||||
open no longer allocates → succeeds → in-band-fails. Ledger, policy
|
||||
count, and manager state balance on every rejection.
|
||||
- Retry policy / UX can branch: `unknown_resource` (bad config — don't
|
||||
retry), `timeout` (maybe retry), `allocate_failed` (capacity —
|
||||
still distinguishable in-band).
|
||||
retry), `timeout` (maybe retry), `dial_failed` (capacity/target
|
||||
refused — maybe retry; amended §2A, was the in-band
|
||||
`allocate_failed`).
|
||||
- The consumer's `open_via_channels` failure is a typed call error —
|
||||
no peeking at the data stream for anything but `allocate_failed`.
|
||||
no peeking at the data stream for anything but the (now
|
||||
unreachable-from-registered-producers) error-frame formality.
|
||||
|
||||
**Negative:**
|
||||
|
||||
@@ -180,9 +257,11 @@ on `establishment_reason()` without unwrapping strings.
|
||||
direct path's failure surface is unchanged.
|
||||
- The `channel:open_failed` reason codes the establisher emits join
|
||||
the wire-stable error set (ADR-049's one-way door).
|
||||
- `allocate_failed` remains a second, in-band failure shape on the
|
||||
channels path — one residual asymmetry, pinned and documented until
|
||||
`Establishment` gains a payload.
|
||||
- **Amendment (0.3.0):** allocation failure changed shape again —
|
||||
`NegotiationRejected{ "allocate_failed" }` (in-band) →
|
||||
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
|
||||
dial_failed })`. The direct path's `allocate_failed` frame is
|
||||
unchanged (two transports, two contracts).
|
||||
|
||||
## Door type
|
||||
|
||||
@@ -205,12 +284,16 @@ ADR moves failure *reporting*, not the negotiation *location*.
|
||||
- ADR-009 (the open op's `input` is the negotiation; the R4
|
||||
amendment's in-band error frames — shrunk to `allocate_failed` by
|
||||
this ADR)
|
||||
- ADR-005 (kill-on-`Drop` — why establisher-side allocation is
|
||||
unsound)
|
||||
- ADR-005 (kill-on-`Drop` — why a *second* establisher-side allocate
|
||||
would be unsound; avoided by construction in the §2A amendment —
|
||||
one allocate per open, consumed by the handler)
|
||||
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct
|
||||
path and the channels `allocate_failed` frame)
|
||||
path; the channels-path peek is now a formality)
|
||||
- ADR-002 (backend selection/allocation ownership)
|
||||
- `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`,
|
||||
`tty_open_spec`
|
||||
- alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
|
||||
plan payload — the upstream capability this ADR's §2A adopts)
|
||||
- `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`,
|
||||
`make_tty_open_handler`, `tty_open_spec`
|
||||
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels`
|
||||
- `src/testing.rs` — the pinned `allocate_failed`-stays-in-band test
|
||||
- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed`
|
||||
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
|
||||
@@ -177,17 +177,18 @@ entering raw mode. The error response shape:
|
||||
On the **direct-ALPN path** this is the failure surface for every
|
||||
negotiation/allocation failure class (below). On the **channels path**
|
||||
the semantic classes (unknown backend, malformed negotiation,
|
||||
ownership denial) are rejected by the establisher before the open
|
||||
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0
|
||||
ADR-049 / alktty ADR-010) — the in-band frames on that path are
|
||||
defense-in-depth arms plus the one non-migratable class
|
||||
(`allocate_failed`, ADR-010 §2).
|
||||
ownership denial) **and allocation** are rejected by the establisher
|
||||
before the open reply and surface as `channel:open_failed` call
|
||||
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
|
||||
`Establishment` plan payload — ADR-010 §2A) — the in-band frames on
|
||||
that path are defense-in-depth arms only (reachable from
|
||||
no-establisher registrations).
|
||||
|
||||
| Error | When | Shape |
|
||||
|-------|------|------|
|
||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
|
||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — on both paths: the establisher cannot carry the allocated handle across to the pump handler (ADR-010 §2), so allocation failure is the one in-band failure class on the channels path | `{"error":"allocate_failed","message":"..."}` |
|
||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — the failure surface of the **direct path**. On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload — ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` |
|
||||
|
||||
After sending the error response, the adapter closes the write half of
|
||||
the bidi stream. The client reads the error frame and treats stream close
|
||||
@@ -369,7 +370,7 @@ architectural commitment.
|
||||
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
|
||||
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
|
||||
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
|
||||
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures are rejected by the establisher (`channel:open_failed`, alkcall ADR-049); `allocate_failed` stays in-band |
|
||||
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures and allocation are rejected/run in the establisher (`channel:open_failed`, alkcall ADR-049; alkcall 0.6 plan payload carries the `TtyHandle`); no in-band frame from a registered producer |
|
||||
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
|
||||
|
||||
## Open Questions
|
||||
@@ -394,8 +395,9 @@ architectural commitment.
|
||||
registry-validated `input` is the negotiation)
|
||||
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
|
||||
channels path's semantic failures are establisher call errors
|
||||
(`channel:open_failed`); the in-band frames shrink to
|
||||
`allocate_failed`
|
||||
(`channel:open_failed`); since alkcall 0.6 the establisher also
|
||||
allocates (the `TtyHandle` crosses via the plan payload), so no
|
||||
failure class arrives in-band from a registered producer
|
||||
- alknet ADR-050 — the ownership model the adapter's access control
|
||||
declares against
|
||||
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
|
||||
|
||||
@@ -125,8 +125,10 @@ pub enum TtyError {
|
||||
|
||||
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed
|
||||
to start, the SSH channel request was rejected. Returned by
|
||||
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and
|
||||
closes (tty-adapter.md §"Negotiation errors").
|
||||
`allocate()`; on the direct path the adapter sends
|
||||
`{"error":"allocate_failed",...}` and closes (tty-adapter.md
|
||||
§"Negotiation errors"); on the channels path the establisher maps it
|
||||
to `channel:open_failed` / `dial_failed` (ADR-010 §2A).
|
||||
- `WaitFailed` — the backend couldn't reap the child / determine the
|
||||
exit code. Returned by the `exit_code` future; the adapter sends
|
||||
`{"type":"exit","code":-1}` (ADR-004 §4).
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# Fuzzing alktty: Plan
|
||||
|
||||
**Status:** adopted — implementation in progress (see §8 progress log)
|
||||
**Date:** 2026-09-28
|
||||
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
|
||||
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
|
||||
runner, and no-CI policy below are adopted from it wholesale rather than
|
||||
re-derived).
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: fuzz this crate
|
||||
|
||||
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
|
||||
"Non-surfaces" explicitly defers the downstream parsers to "downstream
|
||||
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the
|
||||
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
|
||||
owns four parse surfaces fed by arbitrary peers on both transports (direct
|
||||
ALPN and channels):
|
||||
|
||||
| # | Surface | Location | Shape |
|
||||
|---|---|---|---|
|
||||
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
|
||||
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
|
||||
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
|
||||
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
|
||||
|
||||
Reasons this pays off here, beyond the generic alkcall argument:
|
||||
|
||||
1. **Semantic invariants no example test explores adversarially.** The
|
||||
peek/disambiguation machinery (`peek_stream_type` vs
|
||||
`read_chunk_after_peek` — both call orders must yield exactly one
|
||||
chunk) and the cross-codec invariant that makes it sound: an
|
||||
error-response frame's length-prefix high byte must be `0x00` so a
|
||||
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
|
||||
server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If
|
||||
`MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant
|
||||
breaks subtly. A fuzz target spanning both codecs is the cheapest
|
||||
enforcement that exists.
|
||||
2. **The alkcall bug class has a plausible analog here.** The duplicate
|
||||
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
|
||||
unit test replays destroyed the live channel. Alktty's adapter has the
|
||||
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
|
||||
session re-registration, handle replacement. A stateful op-sequence
|
||||
target against the session pump + mock backend is where a real finding
|
||||
would live.
|
||||
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
|
||||
as-built layout is a port, not a design exercise (§5 below).
|
||||
|
||||
Zero parser-bug yield is a successful negative result — it converts "we
|
||||
think the codecs are robust" into a demonstrated property (the quinn
|
||||
lesson cuts both ways).
|
||||
|
||||
**Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not
|
||||
wire-attacker-shaped. The wire-facing side is entirely the
|
||||
shared/producer/consumer modules (`wire`, `negotiation`, `control`,
|
||||
`backend`, `adapter`, `channels`, `session`).
|
||||
|
||||
**The wasm invariant is unaffected:** `fuzz/` is host-only (nightly
|
||||
toolchain, `path` dep on the crate), excluded from publishing, and never
|
||||
compiled for `wasm32-unknown-unknown`.
|
||||
|
||||
## 2. Toolchain decision (adopted from alkcall §4/§7.7)
|
||||
|
||||
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
|
||||
`[workspace]` table (nightly-only bins).
|
||||
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain
|
||||
library crate** — not inline in the target binaries. The `fuzz_target!`
|
||||
binaries are thin wrappers. This makes corpus replay a plain
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
|
||||
replays every committed seed through the identical invariant functions
|
||||
the fuzzer runs, and joins the release verification checklist.
|
||||
- `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the
|
||||
crate stays stable at MSRV 1.88.
|
||||
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
|
||||
deterministic seed generator script committed next to them.
|
||||
- **No hosted CI** — this repo has the same no-CI policy as alkcall
|
||||
(§7.9 there): the gitea host serves git only; verification and
|
||||
campaigns are manual. Corpus replay is the standing fuzz gate;
|
||||
campaigns run via the detached runner before releases and after
|
||||
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
|
||||
adapter, or the session pump. Do not add workflow files or "when CI
|
||||
exists" language.
|
||||
- AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4.
|
||||
|
||||
## 3. Operational isolation: campaigns must be detached from the agent session
|
||||
|
||||
**This is mandatory, not a nice-to-have.** Agent sessions (opencode) run
|
||||
commands through a bash tool that spawns processes as children of the
|
||||
session's own process tree and cgroup. A fuzz campaign is exactly the
|
||||
workload shape that can kill its own host:
|
||||
|
||||
- **OOM-killer shared fate.** The target's worst case *is* unbounded
|
||||
allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge
|
||||
allocation can beat it, and then the kernel OOM killer fires. It picks
|
||||
the largest-RSS process in the cgroup, which can be the opencode server
|
||||
hosting the session. Losing that server is recoverable (sessions are
|
||||
recorded in a db) but hugely derailing and entirely avoidable. An OOM
|
||||
in a fuzz target must cost the fuzzer, never the session.
|
||||
- **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task
|
||||
spawning in a buggy target can starve the session host.
|
||||
- **Deadlock hangs:** a wedged target must not hang the session's bash
|
||||
call.
|
||||
|
||||
Three layers, all standard:
|
||||
|
||||
1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048`
|
||||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
|
||||
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
|
||||
with ASAN (the sanitizer reserves terabytes of virtual address space;
|
||||
the classic failure is instant `MmapAlloc` death) — the ASAN-safe
|
||||
equivalent is `malloc_limit_mb`.
|
||||
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
|
||||
each input in a short-lived child; a crash/timeout/OOM/leak kills only
|
||||
that child, the parent records the artifact and keeps going. Also
|
||||
turns deadlocks into per-input timeouts instead of hung sessions.
|
||||
3. **Process detachment (mandatory for agent-run campaigns):** never run
|
||||
fuzzing as a foreground child of the session. `fuzz/run-detached.sh`
|
||||
wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log
|
||||
file: removed from the session's controlling terminal and signal
|
||||
relation, survives the session ending, and gives a pollable log
|
||||
instead of a blocking call.
|
||||
|
||||
Polling protocol (never `wait` on the detached process):
|
||||
|
||||
```bash
|
||||
tail -n 50 fuzz/artifacts/<target>-*.log # campaign progress
|
||||
ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts
|
||||
pgrep -f "cargo fuzz run <target>" # still running?
|
||||
```
|
||||
|
||||
One session holds one detached campaign per target; log filenames carry
|
||||
UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget
|
||||
(default 600 s).
|
||||
|
||||
## 4. Targets
|
||||
|
||||
In priority order. Targets 1–3 are the initial scope; target 4 is second
|
||||
wave, decided after campaigns on 1–3 come back clean (same sequencing as
|
||||
alkcall §7.4).
|
||||
|
||||
| # | Target | Input style | Drives | Invariants |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
|
||||
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
|
||||
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
|
||||
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
|
||||
|
||||
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
|
||||
|
||||
The consumer disambiguates an error-response frame from a raw chunk by
|
||||
peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4};
|
||||
a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
|
||||
neither. Encoded as fuzz assertions:
|
||||
|
||||
- For every `error_response_bytes` output and every negotiation frame of
|
||||
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader`
|
||||
peek on `[frame bytes…]` never mistakes it for a chunk.
|
||||
- **Known boundary collision, encoded deliberately:** a frame of length
|
||||
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` —
|
||||
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
|
||||
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
|
||||
inherent edge of the one-byte-peek design; producers only ever send
|
||||
tiny error frames, so it is accepted — but the assertion pins the
|
||||
boundary so any future `MAX_CHUNK_LEN` change that widens the
|
||||
collision gets caught. (`validate_header`'s doc comment at
|
||||
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
|
||||
claim holds only for `<` the cap. Worth a one-word fix in the doc
|
||||
comment if we touch `wire.rs` for any other reason.)
|
||||
|
||||
Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid
|
||||
negotiation frame with one extra JSON byte appended *and counted in the
|
||||
length prefix* must fail with a `Json` error, so a future switch to a
|
||||
tolerant streaming reader is caught.
|
||||
|
||||
## 5. Layout (mirrors alkcall §7.7 as-built)
|
||||
|
||||
```
|
||||
fuzz/
|
||||
├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace])
|
||||
├── rust-toolchain.toml pins nightly for this subtree only
|
||||
├── fuzz_targets/
|
||||
│ ├── chunk_frame.rs thin fuzz_target! wrapper
|
||||
│ ├── negotiation_frame.rs thin wrapper
|
||||
│ ├── control_json.rs thin wrapper
|
||||
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
|
||||
├── shared/ alktty-fuzz-shared — STABLE-toolchain library
|
||||
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
|
||||
├── corpus/<target>/ committed seeds
|
||||
├── gen_fuzz_seeds.py deterministic seed generator
|
||||
├── run-detached.sh §3 detached runner (CWD-independent)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
|
||||
|
||||
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without
|
||||
it cargo auto-discovers `fuzz/shared/` into the main workspace and the
|
||||
MSRV toolchain tries to build nightly-consumed dev-deps.
|
||||
- `"fuzz/"` added to the publish `exclude` list.
|
||||
|
||||
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`,
|
||||
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
|
||||
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
|
||||
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
|
||||
(alkcall needed none of its five either). The one private constant,
|
||||
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
|
||||
own copy, and the shape invariants assert the rejection boundary so a
|
||||
drift is caught. The `session_opseq` harness is fuzz-local for the same
|
||||
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
|
||||
backend in `src/` would either leak into the public API or force the
|
||||
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
|
||||
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
|
||||
harness also lives in `fuzz/shared/`).
|
||||
|
||||
## 6. Corpus policy
|
||||
|
||||
Commit hand-made seeds; gitignore grown corpora and artifacts
|
||||
(`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers
|
||||
for every stream type / every control variant, truncation at every prefix
|
||||
length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length =
|
||||
u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave
|
||||
fixtures, cross-codec error-frame fixtures. The generator script is
|
||||
deterministic (no randomness) so seeds are reproducible.
|
||||
|
||||
## 7. Sequencing
|
||||
|
||||
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
|
||||
alktty's own path — `TtySession::open_via_channels` and the channels
|
||||
establisher run on the fixed `ChannelManager`). Full verification set
|
||||
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
|
||||
line while touching it. **Status: see §8.**
|
||||
2. Scaffold `fuzz/` + root workspace table + publish exclude.
|
||||
3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins +
|
||||
seeds + detached runner + README.
|
||||
4. Corpus replay green on stable (`cargo test --manifest-path
|
||||
fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification
|
||||
checklist.
|
||||
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
|
||||
anything found, record results in §8.
|
||||
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
|
||||
**Status: landed — see §8. All four targets are in scope and live.**
|
||||
|
||||
## 8. Progress log (append-only across sessions)
|
||||
|
||||
- **2026-09-28** — Proposal discussed and adopted; this plan written.
|
||||
Alkcall dependency bump + fuzz scaffold started.
|
||||
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.**
|
||||
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
|
||||
all-features 156 tests, clippy stable/wasm, fmt, doc).
|
||||
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
|
||||
replay added to the verification checklist; campaign guidance
|
||||
added.
|
||||
- `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish
|
||||
exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins,
|
||||
invariant logic in `fuzz/shared/` (stable toolchain), 328 committed
|
||||
seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README,
|
||||
`.gitignore`.
|
||||
- Corpus replay green on stable
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
|
||||
- Harness-model corrections the corpus replay caught *before* any
|
||||
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
|
||||
chunk with trailing stream bytes is correct reader behavior — the
|
||||
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing
|
||||
layer is length-prefix-only — `read_frame` returns raw bytes and the
|
||||
adapter does the serde parse (`adapter.rs:411`), so
|
||||
`NegotiationError::Json` is unreachable from this reader (unlike
|
||||
alkcall's serde-backed envelope reader; the `Json` arm now panics as
|
||||
"unreachable" and the trailing-byte probe asserts exact-body
|
||||
consumption instead of a Json error); (3) zero-length negotiation
|
||||
frames are admitted by this reader (the adapter's parse rejects
|
||||
them) — the shape assertion was relaxed accordingly.
|
||||
- **10-min detached campaigns, all three targets: every run exited 0
|
||||
with an empty artifact directory — no crashes, hangs, OOMs, or
|
||||
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
|
||||
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
|
||||
seconds — the 5-byte-parser ceiling is fully enumerated);
|
||||
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
|
||||
budget end — JSON structure exploration); `control_json` 8.6M execs
|
||||
(cov 2035, still growing). Grown corpus entries were deleted per the
|
||||
§6 policy; committed seeds unchanged.
|
||||
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
|
||||
session.
|
||||
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4
|
||||
`session_opseq`. All four targets are now live.**
|
||||
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
|
||||
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl.
|
||||
barrier probes, backend production/EOF, exit resolve/fail/drop,
|
||||
control messages, bounded reads, yields) + a fuzz-local
|
||||
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
|
||||
the harness driving `drive_session_pre_negotiated` over a
|
||||
`tokio::io::duplex` pair, with a delivery model mirroring §4's
|
||||
invariants. 12 committed seeds via the deterministic generator
|
||||
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant
|
||||
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
|
||||
gating each `Vec` element; the encoding is pinned by a decode test
|
||||
asserting the exact ops each seed decodes to).
|
||||
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
|
||||
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
|
||||
prefix-losslessness (post-shutdown chunks never delivered), control
|
||||
dispatch bounds, take-once allocation, teardown termination.
|
||||
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
|
||||
unbounded backend stdin (control dispatch stays prompt).
|
||||
- **Harness-model correction before the campaign** (the alkcall §7.7
|
||||
pattern repeating, third time): a 60s smoke run crashed on
|
||||
`ClientWrite` after the session had completed — a failed client
|
||||
write can mean the *server half of the duplex dropped* because the
|
||||
session finished (exit resolved → pumps join → exit chunk →
|
||||
drainer exits → duplex halves drop) while the write was in flight.
|
||||
The harness now treats any write/shutdown error as "the session
|
||||
closed": it drains the read side and requires the exit chunk (a
|
||||
close without it is a premature close — a real bug). No adapter
|
||||
code changed; the finding was a harness-model gap.
|
||||
- Corpus replay green on stable (9 tests, incl. the seed-decode
|
||||
pin). Full checklist green: 113 + 156 all-features tests, clippy
|
||||
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
|
||||
check.
|
||||
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
|
||||
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all
|
||||
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
|
||||
job (the op-seq target is stateful — each input runs a full tokio
|
||||
session with bounded reads/yields, so exec/s is inherently tens, not
|
||||
millions; the value is interleaving exploration). Cov grew
|
||||
3934 → 4075 edges over the run, still finding coverage at budget
|
||||
end. Grown corpus excised per the §6 policy; 12 committed seeds
|
||||
unchanged; corpus replay green after.
|
||||
- §7 fuzzing plan complete: all four targets live, corpus replay is
|
||||
the standing gate, campaigns pre-release and on adapter/pump/wire
|
||||
changes.
|
||||
|
||||
## 9. Relationship to existing tests
|
||||
|
||||
The existing suite covers valid-input round-trips and documented error
|
||||
paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs`
|
||||
frame tests, `control.rs` message tests). Fuzzing adds: random malformed
|
||||
input exploration (the quinn CVE class), stateful interleavings (target
|
||||
4), and adversarial exploration of the peek/disambiguation seam that
|
||||
example-based tests structurally cannot enumerate.
|
||||
|
||||
Verification additions once landed:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate
|
||||
```
|
||||
|
||||
…added to the AGENTS.md checklist alongside the existing commands. The
|
||||
wasm checks (`cargo check --target wasm32-unknown-unknown`,
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain
|
||||
untouched by fuzz work but must still pass after the root `Cargo.toml`
|
||||
gains its `[workspace]` table.
|
||||
@@ -0,0 +1,7 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/*/
|
||||
corpus/*/*
|
||||
!corpus/*/seed-*
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1330
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "alktty-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alktty-fuzz-shared = { path = "shared" }
|
||||
|
||||
[dependencies.alktty]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "chunk_frame"
|
||||
path = "fuzz_targets/chunk_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "negotiation_frame"
|
||||
path = "fuzz_targets/negotiation_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "control_json"
|
||||
path = "fuzz_targets/control_json.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "session_opseq"
|
||||
path = "fuzz_targets/session_opseq.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,73 @@
|
||||
# alktty fuzzing
|
||||
|
||||
cargo-fuzz targets for the wire-facing parse surfaces. The design and
|
||||
operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
|
||||
`docs/research/fuzzing.md`); this README is the operational cheat-sheet.
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers).
|
||||
- `shared/` — stable-toolchain library holding the invariant logic; the
|
||||
corpus replay tests run here on plain `cargo test`.
|
||||
- `corpus/<target>/` — committed seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py`).
|
||||
- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Drives |
|
||||
|---|---|
|
||||
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
|
||||
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
|
||||
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
|
||||
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
|
||||
|
||||
## Running a campaign — always detached
|
||||
|
||||
Agent sessions must never run fuzzing in the foreground (an OOM in a
|
||||
target can take down the session host; see docs/plans/fuzzing.md §3).
|
||||
Use the detached runner:
|
||||
|
||||
```bash
|
||||
fuzz/run-detached.sh chunk_frame
|
||||
# poll:
|
||||
tail -n 50 fuzz/artifacts/chunk_frame-*.log
|
||||
ls fuzz/artifacts/chunk_frame/
|
||||
pgrep -f "cargo fuzz run chunk_frame"
|
||||
```
|
||||
|
||||
`FUZZ_RUNTIME_SECS=1800 fuzz/run-detached.sh negotiation_frame` for a
|
||||
longer campaign. The runner pins `-fork=1 -rss_limit_mb=2048
|
||||
-malloc_limit_mb=2048 -timeout=25` and detaches via `setsid` + `nohup`.
|
||||
|
||||
## Corpus replay (the standing fuzz gate)
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml
|
||||
```
|
||||
|
||||
Replays every committed seed through the same invariant functions the
|
||||
fuzzer runs, on stable, no nightly needed. Part of the release
|
||||
verification checklist (AGENTS.md).
|
||||
|
||||
## Toolchain notes
|
||||
|
||||
`rust-toolchain.toml` pins nightly for this subtree only (llvm-tools
|
||||
required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
|
||||
`cargo fuzz build` must be run with the fuzz dir as CWD or trust the
|
||||
toolchain file (rustup resolves per directory).
|
||||
|
||||
Run campaigns before releases and after touching `src/wire.rs`,
|
||||
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
|
||||
|
||||
## session_opseq notes
|
||||
|
||||
The op-sequence target is inherently slower than the byte-format targets
|
||||
(each input drives a full tokio session with bounded reads and yields —
|
||||
tens of exec/s per worker, not millions). That is expected: the value is
|
||||
state-space exploration, not throughput. It uses `tokio::time::timeout`
|
||||
internally, so the nightly target needs the `--features time` tokio
|
||||
surface already present via the shared crate's deps — no extra flags.
|
||||
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
|
||||
semantics) are the ones unit tests assert individually; the fuzzer
|
||||
explores interleavings no example test can enumerate.
|
||||
Whitespace-only changes.
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.
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.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
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.
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.
@@ -0,0 +1 @@
|
||||
|
||||
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.
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.
@@ -0,0 +1 @@
|
||||
|
||||
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.
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.
@@ -0,0 +1 @@
|
||||
|
||||
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user